diff --git a/common/luaUtilities/economy/economy_waterfill_solver.lua b/common/luaUtilities/economy/economy_waterfill_solver.lua new file mode 100644 index 00000000000..4fd03618ee8 --- /dev/null +++ b/common/luaUtilities/economy/economy_waterfill_solver.lua @@ -0,0 +1,413 @@ +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") +local SharedConfig = VFS.Include("common/luaUtilities/economy/shared_config.lua") + +local ResourceType = ResourceTypes +local RESOURCE_TYPES = { ResourceTypes.METAL, ResourceTypes.ENERGY } + +local Gadgets = {} +Gadgets.__index = Gadgets + +local EPSILON = 1e-6 +local tracyAvailable = tracy and tracy.ZoneBeginN and tracy.ZoneEnd + +local memberCache = {} ---@type table +local groupCache = {} ---@type table +local groupSizes = {} ---@type table +local teamLedgerCache = {} ---@type table> + +---@param value number? +---@return number +local function normalizeSlider(value) + if type(value) ~= "number" then + return 0 + end + if value > 1 then + value = value * 0.01 + end + if value < 0 then + return 0 + end + if value > 1 then + return 1 + end + return value +end + +---@param teamsList table +---@param resourceType ResourceName +---@return table, table +local function collectMembers(teamsList, resourceType) + for allyTeam in pairs(groupCache) do + groupSizes[allyTeam] = 0 + end + + for teamId, team in pairs(teamsList) do + if not team.isDead then + local resource = team[resourceType] + if resource then + local allyTeam = team.allyTeam + local storage = resource.storage + local excess = resource.excess + local current = resource.current + excess + local shareCursor = storage * normalizeSlider(resource.shareSlider) + if shareCursor > storage then + shareCursor = storage + end + + local member = memberCache[teamId] + if not member then + member = {} + memberCache[teamId] = member + end + member.teamId = teamId + member.allyTeam = allyTeam + member.resourceType = resourceType + member.resource = resource + member.current = current + member.storage = storage + member.shareCursor = shareCursor + + local group = groupCache[allyTeam] + if not group then + group = {} + groupCache[allyTeam] = group + end + local size = (groupSizes[allyTeam] or 0) + 1 + groupSizes[allyTeam] = size + group[size] = member + end + end + end + return groupCache, groupSizes +end + +local LIFT_ITERATIONS = 32 + +local function effectiveSupply(m, target, rate) + if m.current <= target + EPSILON then return 0 end + return (m.current - target) * (1 - rate) +end + +local function demand(m, target) + if m.current >= target - EPSILON then return 0 end + return target - m.current +end + +local function balance(mems, memberCount, lift, rate) + local supply, dem = 0, 0 + for i = 1, memberCount do + local m = mems[i] + local target = math.min(m.shareCursor + lift, m.storage) + supply = supply + effectiveSupply(m, target, rate) + dem = dem + demand(m, target) + end + return supply - dem +end + +local function calcGrossForEffective(effective, taxRate) + if taxRate >= 1 - EPSILON then + return 0 + end + return effective / (1 - taxRate) +end + +---@param members EconomyShareMember[] +---@param memberCount number +---@param taxRate number +---@return number lift +---@return table deltas +local function solveWaterfill(members, memberCount, taxRate) + if memberCount == 0 then + return 0, {} + end + + local maxLift = 0 + for i = 1, memberCount do + local m = members[i] + local headroom = m.storage - m.shareCursor + if headroom > maxLift then maxLift = headroom end + end + + local lo, hi = 0, maxLift + for _ = 1, LIFT_ITERATIONS do + local mid = 0.5 * (lo + hi) + if balance(members, memberCount, mid, taxRate) >= 0 then + lo = mid + else + hi = mid + end + end + local lift = lo + + local totalSupply, totalDemand = 0, 0 + local senderData = {} + local receiverData = {} + + for i = 1, memberCount do + local m = members[i] + local target = math.min(m.shareCursor + lift, m.storage) + if m.current > target + EPSILON then + local eff = effectiveSupply(m, target, taxRate) + senderData[i] = { idx = i, member = m, target = target, effective = eff } + totalSupply = totalSupply + eff + elseif m.current < target - EPSILON then + local dem = demand(m, target) + receiverData[i] = { idx = i, member = m, target = target, demand = dem } + totalDemand = totalDemand + dem + end + end + + local flowRatio = 1 + if totalDemand > EPSILON and totalSupply < totalDemand - EPSILON then + flowRatio = totalSupply / totalDemand + end + -- senders scale down too: over-supply beyond demand stays as excess, not phantom sends + local sendRatio = 1 + if totalSupply > EPSILON and totalDemand < totalSupply - EPSILON then + sendRatio = totalDemand / totalSupply + end + + local deltas = {} + + for i, sd in pairs(senderData) do + local d = { gross = 0, net = 0, taxed = 0 } + local eff = sd.effective * sendRatio + local grossSend = calcGrossForEffective(eff, taxRate) + d.gross = -grossSend + d.taxed = grossSend * taxRate + d.net = -(grossSend - d.taxed) + if math.abs(d.gross) >= EPSILON then + deltas[i] = d + end + end + + for i, rd in pairs(receiverData) do + local d = { gross = 0, net = 0, taxed = 0 } + local received = rd.demand * flowRatio + d.gross = received + d.net = received + if math.abs(d.gross) >= EPSILON then + deltas[i] = d + end + end + + return lift, deltas +end + +---@param members EconomyShareMember[] +---@param memberCount number +---@param lift number +---@param deltas table +---@param taxRate number +---@return table +local function applyDeltas(members, memberCount, lift, deltas, taxRate) + local ledgerCache = {} + + for i = 1, memberCount do + local member = members[i] + local teamId = member.teamId + local delta = deltas[i] + + local ledger = ledgerCache[teamId] + if not ledger then + ledger = { sent = 0, received = 0, taxed = 0, wasted = 0 } + ledgerCache[teamId] = ledger + end + + local target = member.shareCursor + lift + if target > member.storage then + target = member.storage + end + member.target = target + + local resource = member.resource + if delta and math.abs(delta.gross) > EPSILON then + if delta.gross < 0 then + local grossSend = -delta.gross + local taxedReceivable = grossSend * (1 - taxRate) + + local newCurrent = member.current - grossSend + if newCurrent < 0 then newCurrent = 0 end + resource.current = newCurrent + + ledger.sent = grossSend + ledger.taxed = taxedReceivable + else + local received = delta.net + resource.current = member.current + received + + ledger.received = received + end + else + resource.current = member.current + end + end + + for i = 1, memberCount do + local member = members[i] + local resource = member.resource + if resource.current > member.storage then + ledgerCache[member.teamId].wasted = resource.current - member.storage + resource.current = member.storage + end + end + + return ledgerCache +end + +---@param springRepo SpringSynced +---@param teamsList table +---@return table +---@return EconomyFlowSummary +function Gadgets.Solve(springRepo, teamsList) + if tracyAvailable then tracy.ZoneBeginN("WaterfillSolver.Solve") end + + if not teamsList then + if tracyAvailable then tracy.ZoneEnd() end + return teamsList, {} + end + + local teamCount = 0 + for _ in pairs(teamsList) do teamCount = teamCount + 1 end + if teamCount == 0 then + if tracyAvailable then tracy.ZoneEnd() end + return teamsList, {} + end + + -- tax is resolved per ally-group below (uniform tech level within an allyteam) + + for teamId in pairs(teamsList) do + local teamLedger = teamLedgerCache[teamId] + if not teamLedger then + teamLedger = { + [ResourceType.METAL] = {}, + [ResourceType.ENERGY] = {}, + } + teamLedgerCache[teamId] = teamLedger + end + local team = teamsList[teamId] + local m = teamLedger[ResourceType.METAL] + m.sent = 0 + m.received = 0 + m.taxed = 0 + m.wasted = 0 + m.snapshot = team.metal and team.metal.current or 0 + local e = teamLedger[ResourceType.ENERGY] + e.sent = 0 + e.received = 0 + e.taxed = 0 + e.wasted = 0 + e.snapshot = team.energy and team.energy.current or 0 + end + + for _, resourceType in ipairs(RESOURCE_TYPES) do + if tracyAvailable then tracy.ZoneBeginN("CollectMembers:" .. resourceType) end + local groups, sizes = collectMembers(teamsList, resourceType) + if tracyAvailable then tracy.ZoneEnd() end + + for allyTeam, members in pairs(groups) do + local memberCount = sizes[allyTeam] or 0 + if memberCount > 0 then + local taxRate = SharedConfig.getTeamTaxRate(springRepo, members[1].teamId) + local lift, deltas = solveWaterfill(members, memberCount, taxRate) + + if tracyAvailable and tracy.LuaTracyPlot then + tracy.LuaTracyPlot("Economy/Lift/" .. resourceType, lift) + end + + if tracyAvailable then tracy.ZoneBeginN("ApplyDeltas") end + local ledgers = applyDeltas(members, memberCount, lift, deltas, taxRate) + if tracyAvailable then tracy.ZoneEnd() end + + for i = 1, memberCount do + local member = members[i] + local teamId = member.teamId + local ledger = ledgers[teamId] + if ledger then + local summary = teamLedgerCache[teamId][resourceType] + summary.sent = summary.sent + ledger.sent + summary.received = summary.received + ledger.received + summary.taxed = summary.taxed + (ledger.taxed or 0) + summary.wasted = summary.wasted + (ledger.wasted or 0) + end + end + end + end + end + + for teamId, team in pairs(teamsList) do + local ledger = teamLedgerCache[teamId] + if team.metal then + local m = ledger[ResourceType.METAL] + team.metal.sent = m.sent + team.metal.received = m.received + end + if team.energy then + local e = ledger[ResourceType.ENERGY] + team.energy.sent = e.sent + team.energy.received = e.received + end + end + + if tracyAvailable then tracy.ZoneEnd() end + return teamsList, teamLedgerCache +end + +local resultPool = {} + +local function getPooledResult(teamId, resourceType) + local key = teamId * 10 + (resourceType == ResourceType.METAL and 1 or 2) + local entry = resultPool[key] + if not entry then + entry = { teamId = 0, resourceType = "", delta = 0, sent = 0, received = 0, excess = 0 } + resultPool[key] = entry + end + return entry +end + +---@param springRepo SpringSynced +---@param teamsList table +---@return EconomyTeamResult[] +function Gadgets.SolveToResults(springRepo, teamsList) + if tracyAvailable then tracy.ZoneBeginN("WaterfillSolver.SolveToResults") end + + local updatedTeams, allLedgers = Gadgets.Solve(springRepo, teamsList) + + local results = {} + local idx = 0 + + for teamId, team in pairs(updatedTeams) do + local ledger = allLedgers[teamId] + + if team.metal then + idx = idx + 1 + local entry = getPooledResult(teamId, ResourceType.METAL) + local m = ledger[ResourceType.METAL] + entry.teamId = teamId + entry.resourceType = ResourceType.METAL + entry.delta = team.metal.current - m.snapshot + entry.sent = m.sent + entry.received = m.received + entry.excess = m.wasted + results[idx] = entry + end + + if team.energy then + idx = idx + 1 + local entry = getPooledResult(teamId, ResourceType.ENERGY) + local e = ledger[ResourceType.ENERGY] + entry.teamId = teamId + entry.resourceType = ResourceType.ENERGY + entry.delta = team.energy.current - e.snapshot + entry.sent = e.sent + entry.received = e.received + entry.excess = e.wasted + results[idx] = entry + end + end + + if tracyAvailable then tracy.ZoneEnd() end + return results +end + +return Gadgets diff --git a/common/luaUtilities/economy/manual_share_ledger.lua b/common/luaUtilities/economy/manual_share_ledger.lua new file mode 100644 index 00000000000..fc67abffd4c --- /dev/null +++ b/common/luaUtilities/economy/manual_share_ledger.lua @@ -0,0 +1,58 @@ +local M = {} + +local ledger = {} ---@type table> + +local function entry(teamId, resourceType) + local perTeam = ledger[teamId] + if not perTeam then + perTeam = {} + ledger[teamId] = perTeam + end + local e = perTeam[resourceType] + if not e then + e = { sent = 0, received = 0 } + perTeam[resourceType] = e + end + return e +end + +---@param senderTeamId number +---@param receiverTeamId number +---@param resourceType ResourceName +---@param sent number +---@param received number +function M.Record(senderTeamId, receiverTeamId, resourceType, sent, received) + local s = entry(senderTeamId, resourceType) + s.sent = s.sent + sent + local r = entry(receiverTeamId, resourceType) + r.received = r.received + received +end + +--- Fold accumulated manual-share stats into the redistribution result entries, then clear. +---@param results EconomyTeamResult[] +function M.FoldInto(results) + if not next(ledger) then return results end + for i = 1, #results do + local result = results[i] + local perTeam = ledger[result.teamId] + local e = perTeam and perTeam[result.resourceType] + if e then + result.sent = result.sent + e.sent + result.received = result.received + e.received + e.sent = 0 + e.received = 0 + end + end + return results +end + +function M.Clear() + for teamId, perTeam in pairs(ledger) do + for resourceType, e in pairs(perTeam) do + e.sent = 0 + e.received = 0 + end + end +end + +return M diff --git a/common/luaUtilities/economy/shared_config.lua b/common/luaUtilities/economy/shared_config.lua new file mode 100644 index 00000000000..c382a68e270 --- /dev/null +++ b/common/luaUtilities/economy/shared_config.lua @@ -0,0 +1,53 @@ +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") +local MOD_OPTIONS = ModeEnums.ModOptions +local TechBlockingShared = VFS.Include("common/luaUtilities/team_transfer/tech_blocking_shared.lua") + +local M = {} + +---@type number? +local cachedTax = nil +---@type boolean? +local cachedSharingEnabled = nil + +function M.resetCache() + cachedTax = nil + cachedSharingEnabled = nil +end + +---@param springRepo SpringSynced +---@return boolean +function M.isResourceSharingEnabled(springRepo) + if cachedSharingEnabled ~= nil then + return cachedSharingEnabled + end + local v = springRepo.GetModOptions()[MOD_OPTIONS.ResourceSharingEnabled] + cachedSharingEnabled = not (v == false or v == "0") + return cachedSharingEnabled +end + +---@param springRepo SpringSynced +---@return number tax Base tax rate +function M.getTaxConfig(springRepo) + if cachedTax then + return cachedTax + end + + local modOpts = springRepo.GetModOptions() + local tax = tonumber(modOpts[MOD_OPTIONS.TaxResourceSharingAmount]) or 0 + if tax < 0 then tax = 0 end + if tax > 1 then tax = 1 end + + cachedTax = tax + + return tax +end + +-- per-team tax rate by tech level; degrades to base rate when tech_blocking inactive +---@param springRepo SpringSynced +---@param teamId number +---@return number +function M.getTeamTaxRate(springRepo, teamId) + return TechBlockingShared.GetTaxRate(teamId, nil, springRepo) +end + +return M diff --git a/modes/sharing/customize.lua b/modes/sharing/customize.lua new file mode 100644 index 00000000000..a5696c1ada9 --- /dev/null +++ b/modes/sharing/customize.lua @@ -0,0 +1,90 @@ +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +---@type ModeConfig +return { + key = ModeEnums.Modes.Customize, + category = ModeEnums.ModeCategories.Sharing, + name = "Customize", + desc = "Tweak everything. Switching to Customize keeps the current mode's settings as a starting point (defaults if you start here).", + allowRanked = true, + -- non-sticky: keeps current values; entries below only expose/unlock options (values ignored by lobby). + retainValues = true, + modOptions = { + [ModeEnums.ModOptions.TechBlocking] = { + value = false, + locked = false, + }, + [ModeEnums.ModOptions.T2TechThreshold] = { + value = 1, + locked = false, + }, + [ModeEnums.ModOptions.T3TechThreshold] = { + value = 1.5, + locked = false, + }, + [ModeEnums.ModOptions.UnitSharingMode] = { + value = ModeEnums.UnitFilterCategory.All, + locked = false, + }, + [ModeEnums.ModOptions.UnitSharingModeAtT2] = { + value = ModeEnums.UnitFilterCategory.None, + locked = false, + }, + [ModeEnums.ModOptions.UnitSharingModeAtT3] = { + value = ModeEnums.UnitFilterCategory.None, + locked = false, + }, + [ModeEnums.ModOptions.UnitShareStunSeconds] = { + value = 0, + locked = false, + }, + [ModeEnums.ModOptions.UnitStunCategory] = { + value = ModeEnums.UnitFilterCategory.Resource, + locked = false, + }, + [ModeEnums.ModOptions.ConstructorBuildDelay] = { + value = 0, + locked = false, + }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { + value = true, + locked = false, + }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { + value = 0, + locked = false + }, + [ModeEnums.ModOptions.TaxResourceSharingAmountAtT2] = { + value = -1, + locked = false, + }, + [ModeEnums.ModOptions.TaxResourceSharingAmountAtT3] = { + value = -1, + locked = false, + }, + [ModeEnums.ModOptions.AlliedAssistMode] = { + value = ModeEnums.AlliedAssistMode.Enabled, + locked = false + }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { + value = ModeEnums.AlliedUnitReclaimMode.Enabled, + locked = false + }, + [ModeEnums.ModOptions.AllowPartialResurrection] = { + value = ModeEnums.AllowPartialResurrection.Enabled, + locked = false, + }, + [ModeEnums.ModOptions.TakeMode] = { + value = ModeEnums.TakeMode.Enabled, + locked = false, + }, + [ModeEnums.ModOptions.TakeDelaySeconds] = { + value = 30, + locked = false, + }, + [ModeEnums.ModOptions.TakeDelayCategory] = { + value = ModeEnums.UnitCategory.Resource, + locked = false, + }, + } +} diff --git a/modes/sharing/disabled.lua b/modes/sharing/disabled.lua new file mode 100644 index 00000000000..e4353df1f9c --- /dev/null +++ b/modes/sharing/disabled.lua @@ -0,0 +1,37 @@ +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +---@type ModeConfig +return { + key = ModeEnums.Modes.Disabled, + category = ModeEnums.ModeCategories.Sharing, + name = "Disabled", + desc = "Disable all sharing; apply a 30% tax; lock most controls.", + allowRanked = true, + modOptions = { + [ModeEnums.ModOptions.UnitSharingMode] = { + value = ModeEnums.UnitFilterCategory.None, + locked = true + }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { + value = false, + locked = true + }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { + value = 0.30, + locked = false, + ui = "hidden" + }, + [ModeEnums.ModOptions.AlliedAssistMode] = { + value = ModeEnums.AlliedAssistMode.Disabled, + locked = true + }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { + value = ModeEnums.AlliedUnitReclaimMode.Disabled, + locked = true + }, + [ModeEnums.ModOptions.TakeMode] = { + value = ModeEnums.TakeMode.Disabled, + locked = false, + }, + } +} diff --git a/modes/sharing/easy_tax.lua b/modes/sharing/easy_tax.lua new file mode 100644 index 00000000000..5837e18ac8f --- /dev/null +++ b/modes/sharing/easy_tax.lua @@ -0,0 +1,61 @@ +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +---@type ModeConfig +return { + key = ModeEnums.Modes.EasyTax, + category = ModeEnums.ModeCategories.Sharing, + name = "Easy Tax", + desc = "Anti co-op sharing tax mode. Tax on resource sharing, assist, and resurrection. Eco buildings stunned, mobile constructors debuffed.", + allowRanked = true, + -- curated preset: structural choices locked; only intensity dials (rates/durations) stay editable. + modOptions = { + [ModeEnums.ModOptions.UnitSharingMode] = { + value = ModeEnums.UnitFilterCategory.All, + locked = true, + }, + [ModeEnums.ModOptions.UnitShareStunSeconds] = { + value = 30, + locked = false, + }, + [ModeEnums.ModOptions.UnitStunCategory] = { + value = ModeEnums.UnitFilterCategory.Resource, + locked = true, + }, + [ModeEnums.ModOptions.ConstructorBuildDelay] = { + value = 30, + locked = false, + }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { + value = true, + locked = true, + }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { + value = 0.30, + locked = false, + }, + [ModeEnums.ModOptions.AlliedAssistMode] = { + value = ModeEnums.AlliedAssistMode.Enabled, + locked = true, + }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { + value = ModeEnums.AlliedUnitReclaimMode.Enabled, + locked = true, + }, + [ModeEnums.ModOptions.AllowPartialResurrection] = { + value = ModeEnums.AllowPartialResurrection.Enabled, + locked = true, + }, + [ModeEnums.ModOptions.TakeMode] = { + value = ModeEnums.TakeMode.StunDelay, + locked = true, + }, + [ModeEnums.ModOptions.TakeDelaySeconds] = { + value = 30, + locked = false, + }, + [ModeEnums.ModOptions.TakeDelayCategory] = { + value = ModeEnums.UnitCategory.Resource, + locked = true, + }, + }, +} diff --git a/modes/sharing/enabled.lua b/modes/sharing/enabled.lua new file mode 100644 index 00000000000..0be889caec9 --- /dev/null +++ b/modes/sharing/enabled.lua @@ -0,0 +1,37 @@ +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +---@type ModeConfig +return { + key = ModeEnums.Modes.Enabled, + category = ModeEnums.ModeCategories.Sharing, + name = "Enabled", + desc = "All sharing on with fixed defaults.", + allowRanked = true, + modOptions = { + [ModeEnums.ModOptions.UnitSharingMode] = { + value = ModeEnums.UnitFilterCategory.All, + locked = true, + }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { + value = true, + locked = true, + }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { + value = 0.0, + locked = true, + ui = "hidden" + }, + [ModeEnums.ModOptions.AlliedAssistMode] = { + value = ModeEnums.AlliedAssistMode.Enabled, + locked = true, + }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { + value = ModeEnums.AlliedUnitReclaimMode.Enabled, + locked = true, + }, + [ModeEnums.ModOptions.TakeMode] = { + value = ModeEnums.TakeMode.Enabled, + locked = true, + }, + } +} diff --git a/modes/sharing_mode_helpers.lua b/modes/sharing_mode_helpers.lua new file mode 100644 index 00000000000..e3bf4d35ae9 --- /dev/null +++ b/modes/sharing_mode_helpers.lua @@ -0,0 +1,61 @@ +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +local M = {} + +--- Resolve a tech-level-varying modOption: _at_t3, then _at_t2, then baseKey. +function M.resolveByTechLevel(opts, baseKey, techLevel) + if techLevel >= 3 then + local v = opts[baseKey .. "_at_t3"] + if v ~= nil and v ~= "" then return v end + end + if techLevel >= 2 then + local v = opts[baseKey .. "_at_t2"] + if v ~= nil and v ~= "" then return v end + end + return opts[baseKey] +end + +M.unitSharingCategories = { + { key = ModeEnums.UnitCategory.Combat, name = "Combat", desc = "Combat units, commanders, and transports" }, + { key = ModeEnums.UnitCategory.Buildings, name = "Buildings", desc = "Factories, resource, and utility buildings" }, + { key = ModeEnums.UnitCategory.Constructors, name = "Constructors", desc = "Constructors and con turrets" }, + { key = ModeEnums.UnitCategory.Resource, name = "Resource", desc = "Metal extractors and energy producers" }, + { key = ModeEnums.UnitCategory.NonCombat, name = "Non-combat", desc = "Everything except combat units" }, +} + +M.unitSharingCategoriesWithAll = {} +for _, item in ipairs(M.unitSharingCategories) do + M.unitSharingCategoriesWithAll[#M.unitSharingCategoriesWithAll + 1] = item +end +M.unitSharingCategoriesWithAll[#M.unitSharingCategoriesWithAll + 1] = { + key = ModeEnums.UnitFilterCategory.All, name = "All", desc = "All units" +} + +M.unitSharingCategoriesWithNone = { + { key = "", name = "None", desc = "Use the base unit sharing mode" }, +} +for _, item in ipairs(M.unitSharingCategories) do + M.unitSharingCategoriesWithNone[#M.unitSharingCategoriesWithNone + 1] = item +end + +-- tech-level overrides (_at_t2 / _at_t3): None, All, plus individual categories. +M.unitSharingCategoriesWithNoneAndAll = { + { key = ModeEnums.UnitFilterCategory.None, name = "None", desc = "No additional unit sharing at this tech level" }, +} +for _, item in ipairs(M.unitSharingCategories) do + M.unitSharingCategoriesWithNoneAndAll[#M.unitSharingCategoriesWithNoneAndAll + 1] = item +end +M.unitSharingCategoriesWithNoneAndAll[#M.unitSharingCategoriesWithNoneAndAll + 1] = { + key = ModeEnums.UnitFilterCategory.All, name = "All", desc = "All units" +} + +-- None/All wording (not Disabled/Enabled) to match the tech-level selectors. +M.unitSharingModeItems = { + { key = ModeEnums.UnitFilterCategory.None, name = "None", desc = "No unit sharing allowed" }, + { key = ModeEnums.UnitFilterCategory.All, name = "All", desc = "All unit sharing allowed" }, +} +for _, item in ipairs(M.unitSharingCategories) do + M.unitSharingModeItems[#M.unitSharingModeItems + 1] = item +end + +return M diff --git a/modoptions.lua b/modoptions.lua index 19987c94950..9f9fe9440d0 100644 --- a/modoptions.lua +++ b/modoptions.lua @@ -1,3 +1,11 @@ +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") +local ModOptionHelpers = VFS.Include("modes/sharing_mode_helpers.lua") + +local unitSharingModeItems = ModOptionHelpers.unitSharingModeItems +local unitSharingCategoriesWithAll = ModOptionHelpers.unitSharingCategoriesWithAll +local unitSharingCategoriesWithNone = ModOptionHelpers.unitSharingCategoriesWithNone +local unitSharingCategoriesWithNoneAndAll = ModOptionHelpers.unitSharingCategoriesWithNoneAndAll + -- Custom Options Definition Table format -- NOTES: -- using an enumerated table lets you specify the options order @@ -43,6 +51,14 @@ local options = { weight = 7, }, + { + key = ModeEnums.ModeCategories.Sharing, + name = "Sharing", + desc = "Resource and unit sharing options", + type = "section", + weight = 6, + }, + { key = "sub_header", name = "Options for changing base game settings.", @@ -276,48 +292,259 @@ local options = { def = false, }, + -- items must list the valid mode keys so SPADS/unitsync accept a value; Chobby enriches them from modes/sharing/ + { + key = ModeEnums.ModOptions.SharingMode, + name = "Sharing Mode", + desc = "Controls overall sharing policy and locks/unlocks specific options, see `modes/` for more details", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.Modes.Enabled, + items = { + { key = ModeEnums.Modes.Enabled, name = "Enabled" }, + { key = ModeEnums.Modes.Disabled, name = "Disabled" }, + { key = ModeEnums.Modes.EasyTax, name = "Easy Tax" }, + { key = ModeEnums.Modes.Customize, name = "Customize" }, + { key = ModeEnums.Modes.TechCore, name = "Tech Core" }, + }, + }, + + { + key = ModeEnums.ModOptions.TechBlocking, + name = "Tech Blocking", + desc = "Build Keystone buildings to accumulate tech points and unlock T2/T3 units.", + type = "bool", + section = ModeEnums.ModeCategories.Sharing, + def = false, + }, + { + key = ModeEnums.ModOptions.T2TechThreshold, + name = "Keystones per Player for Tech 2", + desc = "Keystones each player needs to unlock Tech 2. Multiplied by team size automatically.", + type = "number", + section = ModeEnums.ModeCategories.Sharing, + def = 1, + min = 0.5, + max = 20, + step = 0.5, + }, + { + key = ModeEnums.ModOptions.T3TechThreshold, + name = "Keystones per Player for Tech 3", + desc = "Keystones each player needs to unlock Tech 3. Multiplied by team size automatically.", + type = "number", + section = ModeEnums.ModeCategories.Sharing, + def = 1.5, + min = 0.5, + max = 20, + step = 0.5, + }, + + { + key = "sub_header", + name = "-- Units", + type = "subheader", + section = ModeEnums.ModeCategories.Sharing, + def = true, + }, { - key = "sub_header", - section = "options_main", - type = "separator", + key = ModeEnums.ModOptions.UnitSharingMode, + name = "Unit Sharing", + desc = "Controls which units can be shared", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.UnitFilterCategory.All, + items = unitSharingModeItems, }, + { + key = ModeEnums.ModOptions.UnitSharingModeAtT2, + name = "Unit Sharing at Tech 2", + desc = "Unit sharing mode that activates when team reaches Tech 2 (requires Tech Blocking)", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.UnitFilterCategory.None, + items = unitSharingCategoriesWithNoneAndAll, + }, + { + key = ModeEnums.ModOptions.UnitSharingModeAtT3, + name = "Unit Sharing at Tech 3", + desc = "Unit sharing mode that activates when team reaches Tech 3 (requires Tech Blocking)", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.UnitFilterCategory.None, + items = unitSharingCategoriesWithNoneAndAll, + }, { - key = "sub_header", - name = "-- Sharing and Taxes", - section = "options_main", - type = "subheader", - def = true, + key = ModeEnums.ModOptions.UnitShareStunSeconds, + name = "Unit Share Stun Duration (seconds)", + desc = "Units matching the stun category are stunned for this many seconds when shared to an ally. Set to 0 to disable stun.", + type = "number", + section = ModeEnums.ModeCategories.Sharing, + def = 0, + min = 0, + max = 120, + step = 1, + column = 1, + }, + { + key = ModeEnums.ModOptions.UnitStunCategory, + name = "Unit Stun Category", + desc = "Which units are stunned when shared to an ally", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.UnitFilterCategory.Resource, + column = 2, + items = unitSharingCategoriesWithAll, }, { - key = "tax_resource_sharing_amount", - name = "Resource Sharing Tax", - desc = "Taxes resource sharing".."\255\128\128\128".." and overflow (engine TODO:)\n".. - "Set to [0] to turn off. Recommended: [0.4]. (Ranges: 0 - 0.99)", + key = ModeEnums.ModOptions.ConstructorBuildDelay, + name = "Constructor Build Delay (seconds)", + desc = "Shared mobile constructors have their build speed set to 0 for this many seconds. They can still move and queue builds, but make no progress until the delay expires. Set to 0 to disable.", + type = "number", + section = ModeEnums.ModeCategories.Sharing, + def = 0, + min = 0, + max = 120, + step = 1, + column = 2, + }, + { + key = ModeEnums.ModOptions.TakeMode, + name = "Take Mode", + desc = "Controls what happens when a player uses /take on an inactive ally's units", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.TakeMode.Enabled, + items = { + { key = ModeEnums.TakeMode.Enabled, name = "Enabled", desc = "All units transfer immediately" }, + { key = ModeEnums.TakeMode.Disabled, name = "Disabled", desc = "Taking is not allowed" }, + { key = ModeEnums.TakeMode.StunDelay, name = "Stun Delay", desc = "Units transfer immediately but affected units are stunned for the delay duration" }, + { key = ModeEnums.TakeMode.TakeDelay, name = "Take Delay", desc = "Unaffected units transfer immediately; affected units require a second /take after the delay" }, + }, + }, + { + key = ModeEnums.ModOptions.TakeDelaySeconds, + name = "Take Delay (seconds)", + desc = "Duration of stun (Stun Delay mode) or wait before second /take (Take Delay mode)", + type = "number", + section = ModeEnums.ModeCategories.Sharing, + def = 30, + min = 0, + max = 120, + step = 1, + column = 1, + }, + { + key = ModeEnums.ModOptions.TakeDelayCategory, + name = "Take Delay Category", + desc = "Which units are affected by the take delay: stunned in Stun Delay mode, or held back in Take Delay mode", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.UnitCategory.Resource, + column = 2, + items = unitSharingCategoriesWithAll, + }, + + { + key = "sub_header", + name = "-- Resources", + type = "subheader", + desc = "", + section = ModeEnums.ModeCategories.Sharing, + def = true, + }, + { + key = ModeEnums.ModOptions.ResourceSharingEnabled, + name = "Resource Sharing Enabled", + desc = "Enable or disable all player-to-player resource sharing and overflow", + type = "bool", + section = ModeEnums.ModeCategories.Sharing, + def = true, + column = 1, + }, + { + key = ModeEnums.ModOptions.TaxResourceSharingAmount, + name = "Resource Sharing Tax Rate", + desc = "Taxes resource shared in any manner.".. + "Set to [0] to turn off. Recommended: [0.3]. (Ranges: 0 - 0.99)", type = "number", def = 0, min = 0, max = 0.99, step = 0.01, - section = "options_main", + section = ModeEnums.ModeCategories.Sharing, column = 1, }, + { + key = ModeEnums.ModOptions.TaxResourceSharingAmountAtT2, + name = "Tax Rate at Tech 2", + desc = "Tax rate when team reaches Tech 2. -1 means no override. (requires Tech Blocking)", + type = "number", + section = ModeEnums.ModeCategories.Sharing, + def = -1, + min = -1, + max = 1.0, + step = 0.01, + }, + { + key = ModeEnums.ModOptions.TaxResourceSharingAmountAtT3, + name = "Tax Rate at Tech 3", + desc = "Tax rate when team reaches Tech 3. -1 means no override. (requires Tech Blocking)", + type = "number", + section = ModeEnums.ModeCategories.Sharing, + def = -1, + min = -1, + max = 1.0, + step = 0.01, + }, + { + key = "sub_header", + name = "-- Allied Interactions", + desc = "", + section = ModeEnums.ModeCategories.Sharing, + type = "subheader", + def = true, + }, { - key = "disable_unit_sharing", - name = "Disable Unit Sharing", - desc = "Disable sharing units and structures to allies", - type = "bool", - section = "options_main", - def = false, + key = ModeEnums.ModOptions.AlliedAssistMode, + name = "Ally Assist", + desc = "Allow units to assist allied construction and repair", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.AlliedAssistMode.Enabled, + column = 1, + items = { + { key = ModeEnums.AlliedAssistMode.Enabled, name = "Enabled", desc = "Enabled" }, + { key = ModeEnums.AlliedAssistMode.Disabled, name = "Disabled", desc = "Disabled" }, + }, }, { - key = "disable_assist_ally_construction", - name = "Disable Assist Ally Construction", - desc = "Disables assisting allied blueprints and labs.", - type = "bool", - section = "options_main", - def = false, - column = 1.66, + key = ModeEnums.ModOptions.AlliedUnitReclaimMode, + name = "Ally Unit Reclaim", + desc = "Allow reclaiming allied units and guarding allied units that can reclaim", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.AlliedUnitReclaimMode.Enabled, + column = 1, + items = { + { key = ModeEnums.AlliedUnitReclaimMode.Enabled, name = "Enabled", desc = "Enabled" }, + { key = ModeEnums.AlliedUnitReclaimMode.Disabled, name = "Disabled", desc = "Disabled" }, + }, }, + { + key = ModeEnums.ModOptions.AllowPartialResurrection, + name = "Allow Partial Resurrection", + desc = "Allow resurrecting partly reclaimed wrecks (disabling prevents tax bypass)", + type = "list", + section = ModeEnums.ModeCategories.Sharing, + def = ModeEnums.AllowPartialResurrection.Enabled, + column = 1, + items = { + { key = ModeEnums.AllowPartialResurrection.Enabled, name = "Enabled", desc = "Enabled" }, + { key = ModeEnums.AllowPartialResurrection.Disabled, name = "Disabled", desc = "Disabled" }, + }, + }, + { key = "sub_header", section = "options_main", @@ -1682,95 +1909,6 @@ local options = { linkwidth = 350, }, - { - key = "easytax", - name = "Easy Tax v2", - desc = "Anti co-op sharing tax mod. Overwrites other tax settings. Don't combine with other sharing restriction mods, everything you need is included with easy tax.", - type = "bool", - section = "options_experimental", - def = false, - }, - - { - key = "easytax_link", - name = "Changelog", - desc = "Easy Tax v2 description.", - section = "options_experimental", - type = "link", - link = "https://gist.github.com/RebelNode/43b986f29b9cfacbe95cf634cac25c49", - width = 215, - column = 1.65, - linkheight = 325, - linkwidth = 350, - }, - - { - key = "tech_blocking", - name = "Tech Blocking", - desc = "Enable tech level blocking system that prevents building units until sufficient tech points are accumulated", - type = "bool", - section = "options_experimental", - def = false, - unlock = {"t2_tech_threshold", "t3_tech_threshold", "unit_creation_reward_multiplier", "tech_blocking_per_team"}, - }, - - { - key = "tech_blocking_link", - name = "Feedback thread", - desc = "Discord discussion about Tech Blocking.", - section = "options_experimental", - type = "link", - link = "https://discord.com/channels/549281623154229250/1447221656228728942/1447221656228728942", - width = 215, - column = 1.65, - linkheight = 325, - linkwidth = 350, - }, - - { - key = "t2_tech_threshold", - name = "Tech 2 Threshold", - desc = "Amount of tech points required to unlock Tech 2 units", - type = "number", - section = "options_experimental", - def = 720, - min = 1, - max = 100000, - step = 1, - }, - - { - key = "t3_tech_threshold", - name = "Tech 3 Threshold", - desc = "Amount of tech points required to unlock Tech 3 units", - type = "number", - section = "options_experimental", - def = 4920, - min = 1, - max = 100000, - step = 1, - }, - - { - key = "tech_blocking_per_team", - name = "Multiply Threshold by Player Count", - desc = "If enabled, tech thresholds are per player. If disabled thresholds are absolute for the whole team", - type = "bool", - section = "options_experimental", - def = true, - }, - - { - key = "unit_creation_reward_multiplier", - name = "Unit Creation Reward Multiplier", - desc = "Multiplier for tech points gained when creating units (0 = disabled, units give no bonus tech points)", - type = "number", - section = "options_experimental", - def = 0, - min = 0, - max = 1.0, - step = 0.001, - }, -- Hidden Tests diff --git a/spec/common/luaUtilities/economy/bar_economy_waterfill_solver_spec.lua b/spec/common/luaUtilities/economy/bar_economy_waterfill_solver_spec.lua new file mode 100644 index 00000000000..c7c2a1b0f00 --- /dev/null +++ b/spec/common/luaUtilities/economy/bar_economy_waterfill_solver_spec.lua @@ -0,0 +1,261 @@ +local Builders = VFS.Include("spec/builders/index.lua") +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") +local TransferEnums = VFS.Include("common/luaUtilities/team_transfer/transfer_enums.lua") +local BarEconomy = VFS.Include("common/luaUtilities/economy/economy_waterfill_solver.lua") +local SharedConfig = VFS.Include("common/luaUtilities/economy/shared_config.lua") + +local function normalizeAllies(teams, allyTeamId) + for i = 1, #teams do + teams[i].allyTeam = allyTeamId + end +end + +local function allyAll(teams, springBuilder) + for i = 1, #teams do + for j = i, #teams do + springBuilder:WithAlliance(teams[i].id, teams[j].id, true) + end + end +end + +local function buildTeams(builders) + local teams = {} + for i = 1, #builders do + local built = builders[i]:Build() + teams[built.id] = built + end + return teams +end + +local function flowFor(flows, teamId, resourceType) + local perTeam = flows[teamId] + assert(perTeam, string.format("missing flow summary for team %s", tostring(teamId))) + local summary = perTeam[resourceType] + assert(summary, string.format("missing flow summary for team %s resource %s", tostring(teamId), tostring(resourceType))) + return summary +end + +local function modOptions(opts) + return { + [ModeEnums.ModOptions.TaxResourceSharingAmount] = opts.taxRate or 0, + } +end + +local function buildSpring(opts, teams) + local builder = Builders.Spring.new() + for key, value in pairs(modOptions(opts)) do + builder:WithModOption(key, value) + end + for i = 1, #teams do + builder:WithTeam(teams[i]) + end + allyAll(teams, builder) + return builder:Build() +end + +describe("Bar economy waterfill", function() + before_each(function() + SharedConfig.resetCache() + end) + + it("balances overflow without tax", function() + local teamA = Builders.Team:new() + :WithMetal(800) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + local teamB = Builders.Team:new() + :WithMetal(700) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + local teamC = Builders.Team:new() + :WithMetal(200) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + + normalizeAllies({ teamA, teamB, teamC }, teamA.allyTeam) + + local spring = buildSpring({ + taxRate = 0, + }, { teamA, teamB, teamC }) + + local teamsList = buildTeams({ teamA, teamB, teamC }) + local _, flows = BarEconomy.Solve(spring, teamsList) + + local a = teamsList[teamA.id].metal + local b = teamsList[teamB.id].metal + local c = teamsList[teamC.id].metal + + assert.is_near(566.67, a.current, 0.02) + assert.is_near(566.67, b.current, 0.02) + assert.is_near(566.67, c.current, 0.02) + + assert.is_near(233.33, a.sent, 0.02) + assert.is_near(133.33, b.sent, 0.02) + assert.is_near(0, c.sent, 1e-6) + + assert.is_near(0, a.received, 1e-6) + assert.is_near(0, b.received, 1e-6) + assert.is_near(366.67, c.received, 0.02) + + local aFlow = flowFor(flows, teamA.id, TransferEnums.ResourceType.METAL) + local bFlow = flowFor(flows, teamB.id, TransferEnums.ResourceType.METAL) + local cFlow = flowFor(flows, teamC.id, TransferEnums.ResourceType.METAL) + assert.is_near(233.33, aFlow.taxed, 0.02) + assert.is_near(133.33, bFlow.taxed, 0.02) + assert.is_near(0, cFlow.taxed, 1e-6) + end) + + it("shares taxed overflow and burns the remainder", function() + local teamA = Builders.Team:new() + :WithMetal(800) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + local teamB = Builders.Team:new() + :WithMetal(700) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + + normalizeAllies({ teamA, teamB }, teamA.allyTeam) + + local spring = buildSpring({ + taxRate = 0.5, + }, { teamA, teamB }) + + local teamsList = buildTeams({ teamA, teamB }) + local _, flows = BarEconomy.Solve(spring, teamsList) + + local a = teamsList[teamA.id].metal + local b = teamsList[teamB.id].metal + + assert.is_near(733.33, a.current, 0.02) + assert.is_near(733.33, b.current, 0.02) + + assert.is_near(66.67, a.sent, 0.02) + assert.is_near(0, a.received, 1e-6) + assert.is_near(0, b.sent, 1e-6) + assert.is_near(33.33, b.received, 0.02) + assert.is_near(a.sent - b.received, 33.33, 0.05) + + local aFlow = flowFor(flows, teamA.id, TransferEnums.ResourceType.METAL) + local bFlow = flowFor(flows, teamB.id, TransferEnums.ResourceType.METAL) + assert.is_near(33.33, aFlow.taxed, 0.05) + assert.is_near(0, bFlow.taxed, 1e-6) + assert.is_near(33.33, bFlow.received, 0.05) + end) + + it("transfers nothing when tax rate is 100 percent", function() + local teamA = Builders.Team:new() + :WithMetal(800) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + local teamB = Builders.Team:new() + :WithMetal(300) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + + normalizeAllies({ teamA, teamB }, teamA.allyTeam) + + local spring = buildSpring({ + taxRate = 1, + }, { teamA, teamB }) + + local teamsList = buildTeams({ teamA, teamB }) + local _, flows = BarEconomy.Solve(spring, teamsList) + + local a = teamsList[teamA.id].metal + local b = teamsList[teamB.id].metal + + -- 100% tax makes every send infinitely expensive, so nothing moves + assert.is_near(800, a.current, 0.01) + assert.is_near(300, b.current, 0.01) + + assert.is_near(0, a.sent, 1e-6) + assert.is_near(0, a.received, 1e-6) + assert.is_near(0, b.sent, 1e-6) + assert.is_near(0, b.received, 1e-6) + + local aFlow = flowFor(flows, teamA.id, TransferEnums.ResourceType.METAL) + local bFlow = flowFor(flows, teamB.id, TransferEnums.ResourceType.METAL) + assert.is_near(0, aFlow.taxed, 1e-6) + assert.is_near(0, bFlow.received, 1e-6) + end) + + local function metalResult(results, teamId) + for _, result in ipairs(results) do + if result.teamId == teamId and result.resourceType == TransferEnums.ResourceType.METAL then + return result + end + end + error("missing metal result for team " .. tostring(teamId)) + end + + it("emits deltas that conserve flows across the group", function() + local teamA = Builders.Team:new() + :WithMetal(800) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + local teamB = Builders.Team:new() + :WithMetal(700) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + local teamC = Builders.Team:new() + :WithMetal(200) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + + normalizeAllies({ teamA, teamB, teamC }, teamA.allyTeam) + + local spring = buildSpring({ taxRate = 0 }, { teamA, teamB, teamC }) + local teamsList = buildTeams({ teamA, teamB, teamC }) + local results = BarEconomy.SolveToResults(spring, teamsList) + + local a = metalResult(results, teamA.id) + local b = metalResult(results, teamB.id) + local c = metalResult(results, teamC.id) + + assert.is_near(-233.33, a.delta, 0.02) + assert.is_near(-133.33, b.delta, 0.02) + assert.is_near(366.67, c.delta, 0.02) + assert.is_near(0, a.delta + b.delta + c.delta, 0.05) + assert.is_near(0, a.excess + b.excess + c.excess, 1e-6) + end) + + it("retains accumulated excess for a neutral team with storage headroom", function() + local teamA = Builders.Team:new() + :WithMetal(500) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + :WithMetalExcess(300) + + local spring = buildSpring({ taxRate = 0 }, { teamA }) + local teamsList = buildTeams({ teamA }) + local results = BarEconomy.SolveToResults(spring, teamsList) + + local a = metalResult(results, teamA.id) + assert.is_near(300, a.delta, 0.01) + assert.is_near(0, a.excess, 1e-6) + assert.is_near(0, a.sent, 1e-6) + assert.is_near(800, teamsList[teamA.id].metal.current, 0.01) + end) + + it("wastes excess beyond storage headroom without phantom sends", function() + local teamA = Builders.Team:new() + :WithMetal(900) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + :WithMetalExcess(300) + + local spring = buildSpring({ taxRate = 0 }, { teamA }) + local teamsList = buildTeams({ teamA }) + local results = BarEconomy.SolveToResults(spring, teamsList) + + local a = metalResult(results, teamA.id) + assert.is_near(100, a.delta, 0.01) + assert.is_near(200, a.excess, 0.01) + assert.is_near(0, a.sent, 1e-6) + -- conservation: injected excess = retained delta + wasted + assert.is_near(300, a.delta + a.excess, 0.01) + assert.is_near(1000, teamsList[teamA.id].metal.current, 0.01) + end) +end) + diff --git a/types/Mode.lua b/types/Mode.lua new file mode 100644 index 00000000000..394e02d1aea --- /dev/null +++ b/types/Mode.lua @@ -0,0 +1,13 @@ +---@class ModOptionConfig +---@field value any The default value for this option +---@field locked boolean Whether this option can be changed by users +---@field ui string|nil UI hints (e.g., "hidden") + +---@class ModeConfig +---@field key string Unique identifier for this mode +---@field category string Mode category (e.g., "sharing") +---@field name string Display name for this mode +---@field desc string Description of this mode +---@field allowRanked boolean Whether this mode is allowed in ranked games +---@field retainValues boolean|nil Non-sticky: keep current modoption values instead of resetting to this preset (Customize). Listed values are ignored in the lobby; visibility/locks still apply. +---@field modOptions table Map of mod option keys to their configurations