From d9f2d4a1af5983e294eeb8def57e4a8ee79e8365 Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Fri, 19 Jun 2026 23:28:31 -0600 Subject: [PATCH] Sharing 2/4: Transfer runtime (controllers, economy boundary, gadget swap) --- common/luaUtilities/economy/share_stats.lua | 72 ++++ .../gui_advplayerlist/validation.lua | 68 ++++ .../team_transfer/policy_events.lua | 28 +- .../luaUtilities/team_transfer/take_comms.lua | 17 + .../team_transfer/unit_sharing_categories.lua | 14 + .../team_transfer/unit_transfer_comms.lua | 39 +- .../team_transfer/unit_transfer_shared.lua | 19 +- .../team_transfer/unit_transfer_synced.lua | 2 + gamedata/modrules.lua | 6 +- luarules/gadgets.lua | 25 +- luarules/gadgets/ai_simpleai.lua | 17 +- luarules/gadgets/cmd_dev_helpers.lua | 4 +- luarules/gadgets/cmd_give.lua | 2 +- luarules/gadgets/cmd_idle_players.lua | 177 +++++++-- luarules/gadgets/cmd_take.lua | 228 +++++++++++ ...t_ally.lua => game_allied_assist_mode.lua} | 29 +- .../gadgets/game_allied_unit_reclaim_mode.lua | 73 ++++ .../game_allow_partial_resurrection.lua | 35 ++ .../game_disable_ally_geo_mex_upgrades.lua | 78 ++++ .../gadgets/game_disable_unit_sharing.lua | 31 -- luarules/gadgets/game_energy_conversion.lua | 3 +- luarules/gadgets/game_no_share_to_enemy.lua | 49 --- .../gadgets/game_prevent_excessive_share.lua | 63 ---- luarules/gadgets/game_quick_start.lua | 3 +- .../game_resource_transfer_controller.lua | 357 ++++++++++++++++++ .../gadgets/game_restrict_resurrection.lua | 31 -- .../gadgets/game_restrict_unit_sharing.lua | 106 ------ .../gadgets/game_share_policy_forwarding.lua | 48 +++ .../gadgets/game_tax_debuff_forwarding.lua | 43 --- .../gadgets/game_tax_resource_sharing.lua | 271 ------------- luarules/gadgets/game_team_resources.lua | 9 +- .../gadgets/game_unit_transfer_controller.lua | 274 ++++++++++++++ luarules/gadgets/unit_builder_priority.lua | 12 +- luarules/gadgets/unit_carrier_spawner.lua | 5 +- luarules/gadgets/unit_cheat_no_waste.lua | 3 +- .../gadgets/unit_geo_upgrade_reclaimer.lua | 2 +- .../gadgets/unit_mex_upgrade_reclaimer.lua | 2 +- luarules/gadgets/unit_prevent_share_load.lua | 4 +- .../gadgets/unit_prevent_share_self_d.lua | 8 +- luarules/gadgets/unit_scenario_loadout.lua | 10 +- luarules/gadgets/unit_zombies.lua | 15 +- .../team_transfer/test_resource_transfer.lua | 82 ++++ .../test_unit_transfer_disabled.lua | 70 ++++ .../test_unit_transfer_enabled.lua | 54 +++ sounds/voice/config.lua | 6 + .../luaUtilities/economy/share_stats_spec.lua | 63 ++++ .../team_transfer/policy_events_spec.lua | 14 + .../team_transfer/take_comms_spec.lua | 22 ++ .../unit_sharing_categories_spec.lua | 22 ++ .../unit_transfer_synced_spec.lua | 81 +++- ...game_resource_transfer_controller_spec.lua | 237 ++++++++++++ tools/headless_testing/startscript.txt | 17 + 52 files changed, 2254 insertions(+), 696 deletions(-) create mode 100644 common/luaUtilities/economy/share_stats.lua create mode 100644 common/luaUtilities/team_transfer/gui_advplayerlist/validation.lua create mode 100644 luarules/gadgets/cmd_take.lua rename luarules/gadgets/{game_disable_assist_ally.lua => game_allied_assist_mode.lua} (88%) create mode 100644 luarules/gadgets/game_allied_unit_reclaim_mode.lua create mode 100644 luarules/gadgets/game_allow_partial_resurrection.lua create mode 100644 luarules/gadgets/game_disable_ally_geo_mex_upgrades.lua delete mode 100644 luarules/gadgets/game_disable_unit_sharing.lua delete mode 100644 luarules/gadgets/game_no_share_to_enemy.lua delete mode 100644 luarules/gadgets/game_prevent_excessive_share.lua create mode 100644 luarules/gadgets/game_resource_transfer_controller.lua delete mode 100644 luarules/gadgets/game_restrict_resurrection.lua delete mode 100644 luarules/gadgets/game_restrict_unit_sharing.lua create mode 100644 luarules/gadgets/game_share_policy_forwarding.lua delete mode 100644 luarules/gadgets/game_tax_debuff_forwarding.lua delete mode 100644 luarules/gadgets/game_tax_resource_sharing.lua create mode 100644 luarules/gadgets/game_unit_transfer_controller.lua create mode 100644 luaui/Tests/team_transfer/test_resource_transfer.lua create mode 100644 luaui/Tests/team_transfer/test_unit_transfer_disabled.lua create mode 100644 luaui/Tests/team_transfer/test_unit_transfer_enabled.lua create mode 100644 spec/common/luaUtilities/economy/share_stats_spec.lua create mode 100644 spec/common/luaUtilities/team_transfer/take_comms_spec.lua create mode 100644 spec/luarules/gadgets/game_resource_transfer_controller_spec.lua diff --git a/common/luaUtilities/economy/share_stats.lua b/common/luaUtilities/economy/share_stats.lua new file mode 100644 index 00000000000..5e246d9b239 --- /dev/null +++ b/common/luaUtilities/economy/share_stats.lua @@ -0,0 +1,72 @@ +-- Lua-side resource-sharing stats (sent / received). +-- +-- The engine keeps the `excess` stat: gadget:ResourceExcess legitimately breaks the +-- engine's conservation ledger (the resource leaves the system), so a callout to record +-- it is fine. sent/received are conserved internal transfers, so -- per sprunk's steer on +-- RecoilEngine#3032 -- we track those in Lua instead of adding engine API for them. +-- +-- The waterfill solver yields *net per-team* flows, which is exactly the shape every +-- consumer wants (top bar, team stats): nobody downstream needs the pairwise +-- who-sent-to-whom, so there is nothing to "unpack" -- the per-team totals map straight +-- through. +-- +-- Transport is team rules params (synced controller writes, unsynced widgets read). +-- Accumulation is read-modify-write on the params themselves, so there is no module-local +-- state to lose across a luarules reload. + +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") +local METAL = ResourceTypes.METAL + +local ShareStats = {} + +local function suffix(resourceType) + return resourceType == METAL and "m" or "e" +end + +-- per-team rules-param keys: cumulative lifetime sent/received, plus the last tick's send +-- (the top bar's overflow indicator used to read this from the engine's per-period resSent) +local function cumSentKey(rt) return "sharestat_" .. suffix(rt) .. "_sent" end +local function cumRecvKey(rt) return "sharestat_" .. suffix(rt) .. "_received" end +local function recentSentKey(rt) return "sharestat_" .. suffix(rt) .. "_sent_recent" end +local function recentRecvKey(rt) return "sharestat_" .. suffix(rt) .. "_received_recent" end + +ShareStats.cumSentKey = cumSentKey +ShareStats.cumRecvKey = cumRecvKey +ShareStats.recentSentKey = recentSentKey +ShareStats.recentRecvKey = recentRecvKey + +-- allies (and spectators) can read; matches the visibility of the engine stats it replaces +local RULES_ACCESS = { allied = true } + +---Record one cadence tick of solver results into the per-team rules params. +---@param springRepo SpringSynced +---@param results EconomyTeamResult[] +function ShareStats.Publish(springRepo, results) + for i = 1, #results do + local r = results[i] + local rt = r.resourceType + local sent = (springRepo.GetTeamRulesParam(r.teamId, cumSentKey(rt)) or 0) + (r.sent or 0) + local received = (springRepo.GetTeamRulesParam(r.teamId, cumRecvKey(rt)) or 0) + (r.received or 0) + springRepo.SetTeamRulesParam(r.teamId, cumSentKey(rt), sent, RULES_ACCESS) + springRepo.SetTeamRulesParam(r.teamId, cumRecvKey(rt), received, RULES_ACCESS) + springRepo.SetTeamRulesParam(r.teamId, recentSentKey(rt), r.sent or 0, RULES_ACCESS) + springRepo.SetTeamRulesParam(r.teamId, recentRecvKey(rt), r.received or 0, RULES_ACCESS) + end +end + +---Read a team's sharing stats for one resource. Fields are nil when no Lua stats have +---been published (e.g. vanilla / native sharing), letting callers fall back to engine values. +---@param springApi table Spring (or a synced-repo) exposing GetTeamRulesParam +---@param teamID number +---@param resourceType ResourceName +---@return { sent: number?, received: number?, sentRecent: number?, receivedRecent: number? } +function ShareStats.Read(springApi, teamID, resourceType) + return { + sent = springApi.GetTeamRulesParam(teamID, cumSentKey(resourceType)), + received = springApi.GetTeamRulesParam(teamID, cumRecvKey(resourceType)), + sentRecent = springApi.GetTeamRulesParam(teamID, recentSentKey(resourceType)), + receivedRecent = springApi.GetTeamRulesParam(teamID, recentRecvKey(resourceType)), + } +end + +return ShareStats diff --git a/common/luaUtilities/team_transfer/gui_advplayerlist/validation.lua b/common/luaUtilities/team_transfer/gui_advplayerlist/validation.lua new file mode 100644 index 00000000000..a2ce2b95580 --- /dev/null +++ b/common/luaUtilities/team_transfer/gui_advplayerlist/validation.lua @@ -0,0 +1,68 @@ +--- Unit validation helpers for advplayerslist.lua +local UnitShared = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_shared.lua") + +local UNIT_VALIDATION_PREFIX = "unit_validation_" + +local UnitValidationFields = { + status = true, + invalidUnitCount = true, + invalidUnitIds = true, + invalidUnitNames = true, + validUnitCount = true, + validUnitIds = true, + validUnitNames = true, + buildDelayedUnitCount = true, + stunnedUnitCount = true, +} + +local validationResultScratch = {} + +local UnitValidationHelpers = {} + +---@param validationResult UnitValidationResult +---@param playerData table +function UnitValidationHelpers.PackSelectedUnitsValidation(validationResult, playerData) + for field, _ in pairs(UnitValidationFields) do + playerData[UNIT_VALIDATION_PREFIX .. field] = validationResult and validationResult[field] or nil + end +end + +---@param playerData table +function UnitValidationHelpers.ClearSelectedUnitsValidation(playerData) + for field, _ in pairs(UnitValidationFields) do + playerData[UNIT_VALIDATION_PREFIX .. field] = nil + end +end + +---@param playerData table +---@return UnitValidationResult | nil +function UnitValidationHelpers.UnpackSelectedUnitsValidation(playerData) + if playerData[UNIT_VALIDATION_PREFIX .. "status"] == nil then + return nil + end + local scratch = validationResultScratch + for field, _ in pairs(UnitValidationFields) do + scratch[field] = playerData[UNIT_VALIDATION_PREFIX .. field] + end + return scratch +end + +---@param player table +---@param myTeamID number +---@param selectedUnits number[] +function UnitValidationHelpers.UpdatePlayerUnitValidations(player, myTeamID, selectedUnits) + for _, playerData in pairs(player) do + if playerData.team and playerData.team ~= myTeamID then + if selectedUnits and #selectedUnits > 0 then + local policyResult = UnitShared.GetCachedPolicyResult(myTeamID, playerData.team, Spring) + local validationResult = UnitShared.ValidateUnits(policyResult, selectedUnits, Spring) + UnitValidationHelpers.PackSelectedUnitsValidation(validationResult, playerData) + else + UnitValidationHelpers.ClearSelectedUnitsValidation(playerData) + end + end + end +end + +return UnitValidationHelpers + diff --git a/common/luaUtilities/team_transfer/policy_events.lua b/common/luaUtilities/team_transfer/policy_events.lua index 7359d0b3064..a20dea0b531 100644 --- a/common/luaUtilities/team_transfer/policy_events.lua +++ b/common/luaUtilities/team_transfer/policy_events.lua @@ -1,5 +1,6 @@ --- Emits a generic "SharePolicyChanged" event when a team's cached sharing policy changes, --- forwarded to widgets via game_share_policy_forwarding.lua -> widget:SharePolicyChanged. +-- Synced-side surface for sharing-policy events forwarded to widgets via +-- game_share_policy_forwarding.lua. Covers team policy changes (SharePolicyChanged) +-- and the per-unit build-delay debuff that the constructor-build-delay policy applies. local PolicyEvents = {} PolicyEvents.Domain = { @@ -42,4 +43,27 @@ function PolicyEvents.NotifyIfChanged(teamId, domain, signature, sendToUnsynced) return true end +---Per-unit manifestation of the constructor-build-delay policy: a builder gained the +---buildspeed debuff for [startFrame, expireFrame). +---@param unitID number +---@param startFrame number +---@param expireFrame number +---@param sendToUnsynced function? defaults to the synced SendToUnsynced global (injectable for tests) +function PolicyEvents.NotifyBuildDelay(unitID, startFrame, expireFrame, sendToUnsynced) + local send = sendToUnsynced or SendToUnsynced + if send then + send("UnitBuildDelayStarted", unitID, startFrame, expireFrame) + end +end + +---The build-delay debuff on a unit ended (expired or unit gone). +---@param unitID number +---@param sendToUnsynced function? defaults to the synced SendToUnsynced global (injectable for tests) +function PolicyEvents.NotifyBuildDelayEnd(unitID, sendToUnsynced) + local send = sendToUnsynced or SendToUnsynced + if send then + send("UnitBuildDelayEnded", unitID) + end +end + return PolicyEvents diff --git a/common/luaUtilities/team_transfer/take_comms.lua b/common/luaUtilities/team_transfer/take_comms.lua index 1995cad607f..c8337641696 100644 --- a/common/luaUtilities/team_transfer/take_comms.lua +++ b/common/luaUtilities/team_transfer/take_comms.lua @@ -15,6 +15,23 @@ function Comms.CategoryDisplayName(category) return categoryDisplayNames[category] or category end +---@class TakePolicy +---@field mode string TakeMode enum value +---@field delaySeconds number +---@field delayCategory string UnitCategory enum value + +---Resolve the global take policy from modoptions. Single source of truth so the take +---action and any consumer read the policy object rather than raw modoptions. +---@param modOptions table +---@return TakePolicy +function Comms.GetPolicy(modOptions) + return { + mode = modOptions[ModeEnums.ModOptions.TakeMode] or ModeEnums.TakeMode.Enabled, + delaySeconds = tonumber(modOptions[ModeEnums.ModOptions.TakeDelaySeconds]) or 30, + delayCategory = modOptions[ModeEnums.ModOptions.TakeDelayCategory] or ModeEnums.UnitCategory.Resource, + } +end + ---@class TakeResult ---@field mode string TakeMode enum value ---@field takerName string Name of the player who issued /take diff --git a/common/luaUtilities/team_transfer/unit_sharing_categories.lua b/common/luaUtilities/team_transfer/unit_sharing_categories.lua index 63d20441d00..fce665f24f6 100644 --- a/common/luaUtilities/team_transfer/unit_sharing_categories.lua +++ b/common/luaUtilities/team_transfer/unit_sharing_categories.lua @@ -78,6 +78,20 @@ function sharing.isConstructorDef(unitDef) return unitDef.isBuilder == true or unitDef.canAssist == true end +---Mobile builders that receive the constructor build delay when shared. Stricter than +---isConstructorDef: immobile nano/con turrets are excluded (they cannot move) and the unit +---must actually be a builder; fast T2 engineers are included since they are mobile builders. +---MUST match the affected set applied in game_unit_transfer_controller so the share +---tooltip's prediction equals what actually happens. +---@param unitDef table +---@return boolean +function sharing.isMobileBuilderDef(unitDef) + return unitDef ~= nil + and unitDef.isBuilder == true + and not unitDef.isImmobile + and not unitDef.isFactory +end + ---Unit-producing factories. ---@param unitDef table ---@return boolean diff --git a/common/luaUtilities/team_transfer/unit_transfer_comms.lua b/common/luaUtilities/team_transfer/unit_transfer_comms.lua index 913d3c0347b..6da9f126f12 100644 --- a/common/luaUtilities/team_transfer/unit_transfer_comms.lua +++ b/common/luaUtilities/team_transfer/unit_transfer_comms.lua @@ -83,6 +83,41 @@ function Comms.DecideCommunicationCase(policy, validationResult) end end +---Append share-time effect notes for the current selection. Unit-aware: each note is +---driven by the validation's affected count, so it only shows (and counts) when units that +---the effect applies to are actually being shared. Covers the constructor build delay and +---the stun-category stun. +---@param text string +---@param policy UnitPolicyResult +---@param validationResult UnitValidationResult? +---@return string +local function withPolicyEffects(text, policy, validationResult) + if not validationResult then + return text + end + + local buildDelay = tonumber(policy.buildDelaySeconds) or 0 + local builderCount = tonumber(validationResult.buildDelayedUnitCount) or 0 + if buildDelay > 0 and builderCount > 0 then + text = text .. " " .. Spring.I18N('ui.playersList.shareUnits.base.buildDelay', { + count = builderCount, + buildDelaySeconds = buildDelay, + }) + end + + local stunSeconds = tonumber(policy.stunSeconds) or 0 + local stunnedCount = tonumber(validationResult.stunnedUnitCount) or 0 + if stunSeconds > 0 and stunnedCount > 0 then + text = text .. " " .. Spring.I18N('ui.playersList.shareUnits.base.stunDelay', { + count = stunnedCount, + stunSeconds = stunSeconds, + stunCategory = policy.stunCategory and Spring.I18N('ui.unitSharingMode.' .. policy.stunCategory) or "", + }) + end + + return text +end + ---@param policy UnitPolicyResult ---@param validationResult UnitValidationResult? function Comms.TooltipText(policy, validationResult) @@ -130,14 +165,14 @@ function Comms.TooltipText(policy, validationResult) local td = techI18nData(policy) for k, v in pairs(td) do i18nData[k] = v end end - return Spring.I18N(u .. '.invalid', i18nData) + return withPolicyEffects(Spring.I18N(u .. '.invalid', i18nData), policy, validationResult) elseif case == TransferEnums.UnitCommunicationCase.OnFullyShareable then local i18nData = {} if validationResult then i18nData.validUnitCount = validationResult.validUnitCount end - return Spring.I18N('ui.playersList.shareUnits.base.default', i18nData) + return withPolicyEffects(Spring.I18N('ui.playersList.shareUnits.base.default', i18nData), policy, validationResult) else error('Invalid unit communication case: ' .. case) end diff --git a/common/luaUtilities/team_transfer/unit_transfer_shared.lua b/common/luaUtilities/team_transfer/unit_transfer_shared.lua index d1fcb70f85a..c7fe58f6046 100644 --- a/common/luaUtilities/team_transfer/unit_transfer_shared.lua +++ b/common/luaUtilities/team_transfer/unit_transfer_shared.lua @@ -10,6 +10,10 @@ local FieldTypes = PolicyShared.FieldTypes Shared.UnitPolicyFields = { canShare = FieldTypes.boolean, sharingModes = FieldTypes.string, + -- carried to the widget so the share tooltip can surface the share-time effects + buildDelaySeconds = FieldTypes.number, + stunSeconds = FieldTypes.number, + stunCategory = FieldTypes.string, } -- Per-team unit policy factors. The unit policy is separable: canShare = @@ -77,6 +81,8 @@ function Shared.ValidateUnits(policyResult, unitIds, springApi, unitDefs) invalidUnitCount = 0, invalidUnitNames = {}, invalidUnitIds = {}, + buildDelayedUnitCount = 0, -- valid units that will receive the constructor build delay + stunnedUnitCount = 0, -- valid units that will be stunned (stun category) } if (not policyResult.canShare) or (not unitIds or #unitIds == 0) then @@ -115,6 +121,12 @@ function Shared.ValidateUnits(policyResult, unitIds, springApi, unitDefs) if ok then out.validUnitCount = out.validUnitCount + 1 table.insert(out.validUnitIds, unitId) + if UnitSharingCategories.isMobileBuilderDef(def) then + out.buildDelayedUnitCount = out.buildDelayedUnitCount + 1 + end + if wouldBeStunned(unitDefID, stunCategory, defs) then + out.stunnedUnitCount = out.stunnedUnitCount + 1 + end if not validUnitNamesSet[unitName] then validUnitNamesSet[unitName] = true table.insert(out.validUnitNames, unitName) @@ -143,8 +155,8 @@ end ---Reconstruct the (sender,receiver) unit policy from cached per-team factors plus live ---gates. Mirrors Synced.GetPolicy: canShare = areAllied AND modeNotNone(sender); cheating ----bypasses only the active check (alliance is still required). Stun config is always read ----live from modoptions. Missing factors fall back to the global unit_sharing_mode. +---bypasses only the active check (alliance is still required). Stun and build-delay config +---is always read live from modoptions. Missing factors fall back to the global unit_sharing_mode. ---@param senderTeamId number ---@param receiverTeamId number ---@param springApi SpringSynced? @@ -154,6 +166,7 @@ function Shared.GetCachedPolicyResult(senderTeamId, receiverTeamId, springApi) local modOptions = spring.GetModOptions() local stunSeconds = tonumber(modOptions[ModeEnums.ModOptions.UnitShareStunSeconds]) or 0 local stunCategory = modOptions[ModeEnums.ModOptions.UnitStunCategory] or ModeEnums.UnitFilterCategory.Resource + local buildDelaySeconds = tonumber(modOptions[ModeEnums.ModOptions.ConstructorBuildDelay]) or 0 local areAllied = (spring.AreTeamsAllied and spring.AreTeamsAllied(senderTeamId, receiverTeamId)) == true @@ -172,6 +185,7 @@ function Shared.GetCachedPolicyResult(senderTeamId, receiverTeamId, springApi) sharingModes = { category }, stunSeconds = stunSeconds, stunCategory = stunCategory, + buildDelaySeconds = buildDelaySeconds, } end @@ -195,6 +209,7 @@ function Shared.GetCachedPolicyResult(senderTeamId, receiverTeamId, springApi) sharingModes = modes, stunSeconds = stunSeconds, stunCategory = stunCategory, + buildDelaySeconds = buildDelaySeconds, } end diff --git a/common/luaUtilities/team_transfer/unit_transfer_synced.lua b/common/luaUtilities/team_transfer/unit_transfer_synced.lua index 2d2cf8cf3be..3c999c31bc5 100644 --- a/common/luaUtilities/team_transfer/unit_transfer_synced.lua +++ b/common/luaUtilities/team_transfer/unit_transfer_synced.lua @@ -23,6 +23,7 @@ function Synced.GetPolicy(ctx) end local stunSeconds = tonumber(modOptions[ModeEnums.ModOptions.UnitShareStunSeconds]) or 0 local stunCategory = modOptions[ModeEnums.ModOptions.UnitStunCategory] or ModeEnums.UnitFilterCategory.Resource + local buildDelaySeconds = tonumber(modOptions[ModeEnums.ModOptions.ConstructorBuildDelay]) or 0 return { canShare = canShare, senderTeamId = ctx.senderTeamId, @@ -30,6 +31,7 @@ function Synced.GetPolicy(ctx) sharingModes = modes, stunSeconds = stunSeconds, stunCategory = stunCategory, + buildDelaySeconds = buildDelaySeconds, techBlocking = ctx.ext and ctx.ext.techBlocking or nil, } end diff --git a/gamedata/modrules.lua b/gamedata/modrules.lua index cd3440bb795..eff4f2b835f 100644 --- a/gamedata/modrules.lua +++ b/gamedata/modrules.lua @@ -90,10 +90,10 @@ local modrules = { }, system = { - allowTake = true, -- Enables and disables the /take UI command. + allowTake = false, -- Engine /take is disabled; the Lua take system (cmd_take) owns /take so it falls through to LuaUI. LuaAllocLimit = 1536, -- default: 1536. Global Lua alloc limit (in megabytes) enableSmoothMesh = true, - + pathFinderSystem = useQTPFS and 1 or 0, -- Which pathfinder does the game use? Can be 0 - The legacy default pathfinder, 1 - Quad-Tree Pathfinder System (QTPFS) or -1 - disabled. --pathFinderUpdateRate = 0.0001, -- default: 0.007. Controls how often the pathfinder updates; larger values means more rapid updates pathFinderRawDistMult = 100000, -- default: 1.25. Engine does raw move with a limited distance, this multiplier adjusts that @@ -102,7 +102,7 @@ local modrules = { pfUpdateRateScale = 1, -- default: 1. Multiplier for the update rate pfRawMoveSpeedThreshold = 0, -- default: 0. Controls the speed modifier (which includes typemap boosts and up/down hill modifiers) under which units will never do raw move, regardless of distance etc. Defaults to 0, which means units will not try to raw-move into unpathable terrain (e.g. typemapped lava, cliffs, water). You can set it to some positive value to make them avoid pathable but very slow terrain (for example if you set it to 0.2 then they will not raw-move across terrain where they move at 20% speed or less, and will use normal pathing instead - which may still end up taking them through that path). pfHcostMult = 0.2, -- default: 0.2. A float value between 0 and 2. Controls how aggressively the pathing search prioritizes nodes going in the direction of the goal. Higher values mean pathing is cheaper, but can start producing degenerate paths where the unit goes straight at the goal and then has to hug a wall. - nativeExcessSharing = Spring.GetModOptions().easytax==false and Spring.GetModOptions().tax_resource_sharing_amount==0, -- default: true. If true, the engine will handle resource overflow sharing between allied teams. If false, overflow sharing is disabled and we use Lua implementation in game_tax_resource_sharing.lua gadget. + nativeExcessSharing = false, -- default: true. If true, the engine will handle resource overflow sharing between allied teams. If false, overflow sharing is disabled and we use Lua implementation in game_tax_resource_sharing.lua gadget. }, transportability = { diff --git a/luarules/gadgets.lua b/luarules/gadgets.lua index 622151c47f4..d8e6106eeea 100644 --- a/luarules/gadgets.lua +++ b/luarules/gadgets.lua @@ -109,6 +109,8 @@ local callInLists = { "GameOver", "GameID", "TeamDied", + "TeamShare", + "ResourceExcess", "PlayerAdded", "PlayerChanged", @@ -787,6 +789,7 @@ function gadgetHandler:RemoveGadgetRaw(gadget) for _, listname in ipairs(callInLists) do ArrayRemove(self[listname .. 'List'], gadget) end + self:DeregisterAllowCommands(gadget) for id, g in pairs(self.CMDIDs) do @@ -803,7 +806,7 @@ end function gadgetHandler:UpdateCallIn(name) local listName = name .. 'List' - local forceUpdate = (name == 'GotChatMsg' or name == 'RecvFromSynced') -- redundant? + local forceUpdate = (name == 'GotChatMsg' or name == 'RecvFromSynced') _G[name] = nil @@ -1307,6 +1310,26 @@ function gadgetHandler:TeamDied(teamID) return end +function gadgetHandler:TeamShare(teamID, targetTeamID, metalShare, energyShare) + for _, g in ipairs(self.TeamShareList) do + g:TeamShare(teamID, targetTeamID, metalShare, energyShare) + end + return +end + +-- Engine fires this every frame (CSyncedLuaHandle::ResourceExcess); returning true from any +-- gadget tells the engine that overflow was handled, so it skips native buffering. Single +-- owner is a game-side convention. +function gadgetHandler:ResourceExcess(excesses) + local handled = false + for _, g in ipairs(self.ResourceExcessList) do + if g:ResourceExcess(excesses) then + handled = true + end + end + return handled +end + function gadgetHandler:TeamChanged(teamID) for _, g in ipairs(self.TeamChangedList) do g:TeamChanged(teamID) diff --git a/luarules/gadgets/ai_simpleai.lua b/luarules/gadgets/ai_simpleai.lua index d529fa8fd41..30fd7e65877 100644 --- a/luarules/gadgets/ai_simpleai.lua +++ b/luarules/gadgets/ai_simpleai.lua @@ -58,6 +58,7 @@ local MakeHashedPosTable = VFS.Include("luarules/utilities/damgam_lib/hashpostab local HashPosTable = MakeHashedPosTable() local positionCheckLibrary = VFS.Include("luarules/utilities/damgam_lib/position_checks.lua") +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") -- manually appoint units to avoid making -- (note that transports, stockpilers and objects/walls are auto skipped) @@ -187,12 +188,10 @@ local spGetUnitPosition = Spring.GetUnitPosition local spGetUnitCommandCount = Spring.GetUnitCommandCount local spGetUnitHealth = Spring.GetUnitHealth local spGetUnitAllyTeam = Spring.GetUnitAllyTeam -local spGetTeamResources = Spring.GetTeamResources local spTestBuildOrder = Spring.TestBuildOrder local spGetFullBuildQueue = Spring.GetFullBuildQueue local spGetTeamUnits = Spring.GetTeamUnits local spGetAllUnits = Spring.GetAllUnits -local spSetTeamResource = Spring.SetTeamResource local spGetTeamInfo = Spring.GetTeamInfo local spGetTeamLuaAI = Spring.GetTeamLuaAI local spDgunCommand = CMD.DGUN @@ -320,8 +319,8 @@ local function SimpleConstructionProjectSelection(unitID, unitDefID, unitTeam, u --tracy.ZoneBeginN("SimpleAI:SimpleConstructionProjectSelection") local success = false - local mcurrent, mstorage, _, _, _ = spGetTeamResources(unitTeam, "metal") - local ecurrent, estorage, _, _, _ = spGetTeamResources(unitTeam, "energy") + local mcurrent, mstorage, _, _, _ = GG.GetTeamResources(unitTeam, "metal") + local ecurrent, estorage, _, _, _ = GG.GetTeamResources(unitTeam, "energy") local unitposx, _, unitposz = spGetUnitPosition(unitID) local buildOptions = BuildOptions[unitDefID] @@ -487,15 +486,15 @@ if gadgetHandler:IsSyncedCode() then --tracy.ZoneBeginN("SimpleAI:GameFrame") local teamID = SimpleAITeamIDs[i] local _, _, _, _, _, allyTeamID = spGetTeamInfo(teamID) - local mcurrent, mstorage = spGetTeamResources(teamID, "metal") - local ecurrent, estorage = spGetTeamResources(teamID, "energy") + local mcurrent, mstorage = GG.GetTeamResources(teamID, "metal") + local ecurrent, estorage = GG.GetTeamResources(teamID, "energy") -- resource boost (teamID is always in SimpleAITeamIDs) if mcurrent < mstorage * 0.20 then - spSetTeamResource(teamID, "m", mstorage * 0.25) + Spring.SetTeamResource(teamID, ResourceTypes.METAL, mstorage * 0.25) end if ecurrent < estorage * 0.20 then - spSetTeamResource(teamID, "e", estorage * 0.25) + Spring.SetTeamResource(teamID, ResourceTypes.ENERGY, estorage * 0.25) end local luaAI = spGetTeamLuaAI(teamID) @@ -528,7 +527,7 @@ if gadgetHandler:IsSyncedCode() then if nearestEnemy and unitHealthPercentage > 30 then if ecurrent < estorage*0.9 then - spSetTeamResource(teamID, "e", estorage*0.9) + Spring.SetTeamResource(teamID, ResourceTypes.ENERGY, estorage * 0.9) end spGiveOrderToUnit(unitID, spDgunCommand, {nearestEnemy}, 0) local nearestEnemies = spGetUnitsInCylinder(unitposx, unitposz, 300) diff --git a/luarules/gadgets/cmd_dev_helpers.lua b/luarules/gadgets/cmd_dev_helpers.lua index b8a29663024..472859fc7b7 100644 --- a/luarules/gadgets/cmd_dev_helpers.lua +++ b/luarules/gadgets/cmd_dev_helpers.lua @@ -632,8 +632,8 @@ if gadgetHandler:IsSyncedCode() then local teamID = Spring.GetUnitTeam(unitID) local unitDefID = Spring.GetUnitDefID(unitID) Spring.DestroyUnit(unitID, false, true) -- this doesnt give back resources in itself - Spring.AddTeamResource(teamID, 'metal', UnitDefs[unitDefID].metalCost) - Spring.AddTeamResource(teamID, 'energy', UnitDefs[unitDefID].energyCost) + GG.AddTeamResource(teamID, 'metal', UnitDefs[unitDefID].metalCost) + GG.AddTeamResource(teamID, 'energy', UnitDefs[unitDefID].energyCost) elseif action == 'wreck' then local unitDefID = Spring.GetUnitDefID(unitID) local x, y, z = Spring.GetUnitPosition(unitID) diff --git a/luarules/gadgets/cmd_give.lua b/luarules/gadgets/cmd_give.lua index 030db040de7..9aa56b5a9bd 100644 --- a/luarules/gadgets/cmd_give.lua +++ b/luarules/gadgets/cmd_give.lua @@ -54,7 +54,7 @@ if gadgetHandler:IsSyncedCode() then -- give resources if unitName == "metal" or unitName == "energy" then -- Give resources instead of units - Spring.AddTeamResource(teamID, unitName, amount) + GG.AddTeamResource(teamID, unitName, amount) Spring.SendMessageToTeam(teamID, "You have been given: "..amount.." "..unitName) Spring.SendMessageToPlayer(playerID, "You have given team "..teamID..": "..amount.." "..unitName) return diff --git a/luarules/gadgets/cmd_idle_players.lua b/luarules/gadgets/cmd_idle_players.lua index b0dd64f3938..57ecfc6bb36 100644 --- a/luarules/gadgets/cmd_idle_players.lua +++ b/luarules/gadgets/cmd_idle_players.lua @@ -39,13 +39,15 @@ local errorKeys = { if gadgetHandler:IsSyncedCode() then + local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + local Shared = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_shared.lua") + local TakeComms = VFS.Include("common/luaUtilities/team_transfer/take_comms.lua") + local playerInfoTable = {} local currentGameFrame = 0 local TransferUnit = Spring.TransferUnit local GetPlayerList = Spring.GetPlayerList - local ShareTeamResource = Spring.ShareTeamResource - local GetTeamResources = Spring.GetTeamResources local GetPlayerInfo = Spring.GetPlayerInfo local GetTeamLuaAI = Spring.GetTeamLuaAI local GetAIInfo = Spring.GetAIInfo @@ -60,7 +62,38 @@ if gadgetHandler:IsSyncedCode() then local gaiaTeamID = Spring.GetGaiaTeamID() local gameSpeed = Game.gameSpeed - local validation = string.randomString(2) + local modOptions = Spring.GetModOptions() + local takeMode = modOptions[ModeEnums.ModOptions.TakeMode] or ModeEnums.TakeMode.Enabled + local takeDelaySeconds = tonumber(modOptions[ModeEnums.ModOptions.TakeDelaySeconds]) or 30 + local takeDelayCategory = modOptions[ModeEnums.ModOptions.TakeDelayCategory] or ModeEnums.UnitCategory.Resource + local pendingDelayedTakes = {} + + local function matchesCategory(unitDefID, category) + if category == ModeEnums.UnitFilterCategory.All then + return true + end + return Shared.IsShareableDef(unitDefID, category, UnitDefs) + end + + local function stunUnit(unitID, seconds) + local _, maxHealth = Spring.GetUnitHealth(unitID) + if maxHealth and maxHealth > 0 then + Spring.AddUnitDamage(unitID, maxHealth * 5, seconds * 30) + end + end + + local charset = {} do -- [0-9a-zA-Z] + for c = 48, 57 do table.insert(charset, string.char(c)) end + for c = 65, 90 do table.insert(charset, string.char(c)) end + for c = 97, 122 do table.insert(charset, string.char(c)) end + end + + local function randomString(length) + if not length or length <= 0 then return '' end + return randomString(length - 1) .. charset[math.random(1, #charset)] + end + + local validation = randomString(2) _G.validationIdle = validation local function CheckPlayerState(playerID) @@ -140,18 +173,56 @@ if gadgetHandler:IsSyncedCode() then end end + local function transferResources(fromTeamID, toTeamID) + for _, resourceName in ipairs(resourceList) do + local shareAmount = GG.GetTeamResources(fromTeamID, resourceName) + local current,storage,_,_,_,shareSlider = GG.GetTeamResources(toTeamID, resourceName) + shareAmount = math.min(shareAmount, shareSlider * storage - current) + GG.ShareTeamResource(fromTeamID, toTeamID, resourceName, shareAmount) + end + end + + local function getPlayerName(pID) + local name = GetPlayerInfo(pID, false) + return name or ("Player " .. pID) + end + + local function getTeamLeaderName(tID) + local _, leaderID = GetTeamInfo(tID, false) + if leaderID then + return getPlayerName(leaderID) + end + return "Team " .. tID + end + + local function notifyTake(playerID, result) + local msg = TakeComms.FormatMessage(result) + if msg and msg ~= "" then + SendToUnsynced("TakeNotify", playerID, msg) + end + end + local function takeTeam(cmd, line, words, playerID) if not CheckPlayerState(playerID) then SendToUnsynced("NotifyError", playerID, errorKeys.shareAFK) - return -- exclude taking rights from lagged players, etc + return + end + + local takerName = getPlayerName(playerID) + + if takeMode == ModeEnums.TakeMode.Disabled then + notifyTake(playerID, { mode = takeMode, takerName = takerName, sourceName = "", transferred = 0, stunned = 0, delayed = 0, total = 0, category = takeDelayCategory, delaySeconds = takeDelaySeconds }) + return end + + Spring.SetGameRulesParam("isTakeInProgress", 1) local targetTeam = tonumber(words[1]) local _,_,_,takerID,allyTeamID = GetPlayerInfo(playerID,false) local teamList = GetTeamList(allyTeamID) if targetTeam then if select(6, GetTeamInfo(targetTeam, false)) ~= allyTeamID then - -- don't let enemies take SendToUnsynced("NotifyError", playerID, errorKeys.takeEnemies) + Spring.SetGameRulesParam("isTakeInProgress", 0) return end teamList = {targetTeam} @@ -162,20 +233,78 @@ if gadgetHandler:IsSyncedCode() then local isAiTeam = select(4, GetTeamInfo(teamID, false)) if not isAiTeam and (not luaAI or luaAI == "") and GetTeamRulesParam(teamID,"numActivePlayers") == 0 then numToTake = numToTake + 1 - -- transfer all units - local teamUnits = GetTeamUnits(teamID) - for i=1, #teamUnits do - TransferUnit(teamUnits[i], takerID) - end - -- send all resources en-block to the taker - for _, resourceName in ipairs(resourceList) do - local shareAmount = GetTeamResources(teamID, resourceName) - local current,storage,_,_,_,shareSlider = GetTeamResources(takerID, resourceName) - shareAmount = math.min(shareAmount,shareSlider*storage-current) - ShareTeamResource( teamID, takerID, resourceName, shareAmount ) + local sourceName = getTeamLeaderName(teamID) + + if takeMode == ModeEnums.TakeMode.Enabled then + local teamUnits = GetTeamUnits(teamID) + local transferred = #teamUnits + for i=1, #teamUnits do + TransferUnit(teamUnits[i], takerID) + end + transferResources(teamID, takerID) + notifyTake(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = transferred, stunned = 0, delayed = 0, total = transferred, category = takeDelayCategory, delaySeconds = 0 }) + + elseif takeMode == ModeEnums.TakeMode.StunDelay then + local teamUnits = GetTeamUnits(teamID) + local transferred = #teamUnits + for i=1, #teamUnits do + TransferUnit(teamUnits[i], takerID) + end + local stunned = 0 + if takeDelaySeconds > 0 then + for _, unitID in ipairs(GetTeamUnits(takerID)) do + local unitDefID = Spring.GetUnitDefID(unitID) + if unitDefID and matchesCategory(unitDefID, takeDelayCategory) then + stunUnit(unitID, takeDelaySeconds) + stunned = stunned + 1 + end + end + end + transferResources(teamID, takerID) + notifyTake(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = transferred, stunned = stunned, delayed = 0, total = transferred, category = takeDelayCategory, delaySeconds = takeDelaySeconds }) + + elseif takeMode == ModeEnums.TakeMode.TakeDelay then + local pending = pendingDelayedTakes[teamID] + local delayFrames = takeDelaySeconds * 30 + + if pending and pending.takerTeamID == takerID then + if currentGameFrame >= pending.expiryFrame then + local units = GetTeamUnits(teamID) + local transferred = #units + for _, unitID in ipairs(units) do + TransferUnit(unitID, takerID) + end + transferResources(teamID, takerID) + pendingDelayedTakes[teamID] = nil + notifyTake(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = transferred, stunned = 0, delayed = 0, total = transferred, category = takeDelayCategory, delaySeconds = takeDelaySeconds, isSecondPass = true }) + else + local remaining = math.ceil((pending.expiryFrame - currentGameFrame) / 30) + notifyTake(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = 0, stunned = 0, delayed = 0, total = 0, category = takeDelayCategory, delaySeconds = takeDelaySeconds, remainingSeconds = remaining }) + end + else + local units = GetTeamUnits(teamID) + local total = #units + local transferred = 0 + local delayed = 0 + for _, unitID in ipairs(units) do + local unitDefID = Spring.GetUnitDefID(unitID) + if unitDefID and not matchesCategory(unitDefID, takeDelayCategory) then + TransferUnit(unitID, takerID) + transferred = transferred + 1 + else + delayed = delayed + 1 + end + end + pendingDelayedTakes[teamID] = { + takerTeamID = takerID, + expiryFrame = currentGameFrame + delayFrames, + } + notifyTake(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = transferred, stunned = 0, delayed = delayed, total = total, category = takeDelayCategory, delaySeconds = takeDelaySeconds }) + end end end end + Spring.SetGameRulesParam("isTakeInProgress", 0) if numToTake == 0 then SendToUnsynced("NotifyError", playerID, errorKeys.nothingToTake) end @@ -223,17 +352,6 @@ if gadgetHandler:IsSyncedCode() then end end - function gadget:AllowResourceTransfer(fromTeamID, toTeamID, restype, level) - -- prevent resources to leak to uncontrolled teams - return GetTeamRulesParam(toTeamID,"numActivePlayers") ~= 0 or IsCheatingEnabled() - end - - function gadget:AllowUnitTransfer(unitID, unitDefID, fromTeamID, toTeamID, capture) - -- prevent units to be shared to uncontrolled teams - return capture or GetTeamRulesParam(toTeamID,"numActivePlayers") ~= 0 or IsCheatingEnabled() - end - - else -- UNSYNCED @@ -304,6 +422,10 @@ else -- UNSYNCED end end + local function takeNotify(_, playerID, message) + Spring.SendMessageToPlayer(playerID, message) + end + local function notifyError(_, playerID, errorKey) if Script.LuaUI('GadgetMessageProxy') then local translationKey = 'ui.idlePlayers.' .. errorKey @@ -340,6 +462,7 @@ else -- UNSYNCED function gadget:Initialize() gadgetHandler:AddSyncAction("OnGameStart", onGameStart) gadgetHandler:AddSyncAction("NotifyError", notifyError) + gadgetHandler:AddSyncAction("TakeNotify", takeNotify) gadgetHandler:AddSyncAction("PlayerLagging", playerLagging) gadgetHandler:AddSyncAction("PlayerResumed", playerResumed) gadgetHandler:AddSyncAction("PlayerAFK", playerAFK) diff --git a/luarules/gadgets/cmd_take.lua b/luarules/gadgets/cmd_take.lua new file mode 100644 index 00000000000..9af4b7ee0e3 --- /dev/null +++ b/luarules/gadgets/cmd_take.lua @@ -0,0 +1,228 @@ +function gadget:GetInfo() + return { + name = "Take Command", + desc = "Implements /take command to transfer units/resources from empty allied teams", + author = "Antigravity", + date = "2024", + license = "GPL-v2", + layer = 0, + enabled = true, + } +end + +if not gadgetHandler:IsSyncedCode() then + return +end + +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") +local Shared = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_shared.lua") +local TakeComms = VFS.Include("common/luaUtilities/team_transfer/take_comms.lua") + +local TAKE_MSG = "take_cmd" + +local takePolicy = TakeComms.GetPolicy(Spring.GetModOptions()) +local takeMode = takePolicy.mode +local takeDelaySeconds = takePolicy.delaySeconds +local takeDelayCategory = takePolicy.delayCategory + +local pendingDelayedTakes = {} + +local function hasActivePlayer(otherTeamID) + for _, pID in ipairs(Spring.GetPlayerList()) do + local _, active, spectator, pTeamID = Spring.GetPlayerInfo(pID) + if active and not spectator and pTeamID == otherTeamID then + return true + end + end + return false +end + +local function transferResources(fromTeamID, toTeamID) + local metal = GG.GetTeamResources and GG.GetTeamResources(fromTeamID, "metal") + local energy = GG.GetTeamResources and GG.GetTeamResources(fromTeamID, "energy") + if metal and metal > 0 and GG.ShareTeamResource then + GG.ShareTeamResource(fromTeamID, toTeamID, "metal", metal) + end + if energy and energy > 0 and GG.ShareTeamResource then + GG.ShareTeamResource(fromTeamID, toTeamID, "energy", energy) + end +end + +local function matchesCategory(unitDefID, category) + if category == ModeEnums.UnitFilterCategory.All then + return true + end + return Shared.IsShareableDef(unitDefID, category, UnitDefs) +end + +local function stunUnit(unitID, seconds) + local _, maxHealth = Spring.GetUnitHealth(unitID) + if maxHealth and maxHealth > 0 then + Spring.AddUnitDamage(unitID, maxHealth * 5, seconds * 30) + end +end + +local function getPlayerName(playerID) + local name = Spring.GetPlayerInfo(playerID) + return name or ("Player " .. playerID) +end + +local function getTeamLeaderName(teamID) + local _, leaderID = Spring.GetTeamInfo(teamID, false) + if leaderID then + return getPlayerName(leaderID) + end + return "Team " .. teamID +end + +local function notify(playerID, result) + local msg = TakeComms.FormatMessage(result) + if msg and msg ~= "" then + Spring.SendMessageToPlayer(playerID, msg) + end +end + +local function ExecuteTake(playerID) + local takerName, _, spec, teamID = Spring.GetPlayerInfo(playerID) + if spec then return end + + if takeMode == ModeEnums.TakeMode.Disabled then + notify(playerID, { mode = takeMode, takerName = takerName, sourceName = "", transferred = 0, stunned = 0, delayed = 0, total = 0, category = takeDelayCategory, delaySeconds = takeDelaySeconds }) + return + end + + Spring.SetGameRulesParam("isTakeInProgress", 1) + + local allyTeamID = Spring.GetTeamAllyTeamID(teamID) + local teamList = Spring.GetTeamList(allyTeamID) + local currentFrame = Spring.GetGameFrame() + local numTargets = 0 + + for _, otherTeamID in ipairs(teamList) do + -- Take any allied team with no active human player, including AI allies. + if otherTeamID ~= teamID and not hasActivePlayer(otherTeamID) then + numTargets = numTargets + 1 + local sourceName = getTeamLeaderName(otherTeamID) + + if takeMode == ModeEnums.TakeMode.Enabled then + local units = Spring.GetTeamUnits(otherTeamID) + local transferred = #units + for _, unitID in ipairs(units) do + Spring.TransferUnit(unitID, teamID, true) + end + transferResources(otherTeamID, teamID) + notify(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = transferred, stunned = 0, delayed = 0, total = transferred, category = takeDelayCategory, delaySeconds = 0 }) + + elseif takeMode == ModeEnums.TakeMode.StunDelay then + local units = Spring.GetTeamUnits(otherTeamID) + local transferred = #units + for _, unitID in ipairs(units) do + Spring.TransferUnit(unitID, teamID, true) + end + local stunned = 0 + if takeDelaySeconds > 0 then + for _, unitID in ipairs(Spring.GetTeamUnits(teamID)) do + local unitDefID = Spring.GetUnitDefID(unitID) + if unitDefID and matchesCategory(unitDefID, takeDelayCategory) then + stunUnit(unitID, takeDelaySeconds) + stunned = stunned + 1 + end + end + end + transferResources(otherTeamID, teamID) + notify(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = transferred, stunned = stunned, delayed = 0, total = transferred, category = takeDelayCategory, delaySeconds = takeDelaySeconds }) + + elseif takeMode == ModeEnums.TakeMode.TakeDelay then + local pending = pendingDelayedTakes[otherTeamID] + local delayFrames = takeDelaySeconds * 30 + + if pending and pending.takerTeamID == teamID then + if currentFrame >= pending.expiryFrame then + local units = Spring.GetTeamUnits(otherTeamID) + local transferred = #units + for _, unitID in ipairs(units) do + Spring.TransferUnit(unitID, teamID, true) + end + transferResources(otherTeamID, teamID) + pendingDelayedTakes[otherTeamID] = nil + notify(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = transferred, stunned = 0, delayed = 0, total = transferred, category = takeDelayCategory, delaySeconds = takeDelaySeconds, isSecondPass = true }) + else + local remaining = math.ceil((pending.expiryFrame - currentFrame) / 30) + notify(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = 0, stunned = 0, delayed = 0, total = 0, category = takeDelayCategory, delaySeconds = takeDelaySeconds, remainingSeconds = remaining }) + end + else + local units = Spring.GetTeamUnits(otherTeamID) + local total = #units + local transferred = 0 + local delayed = 0 + for _, unitID in ipairs(units) do + local unitDefID = Spring.GetUnitDefID(unitID) + if unitDefID and not matchesCategory(unitDefID, takeDelayCategory) then + Spring.TransferUnit(unitID, teamID, true) + transferred = transferred + 1 + else + delayed = delayed + 1 + end + end + pendingDelayedTakes[otherTeamID] = { + takerTeamID = teamID, + expiryFrame = currentFrame + delayFrames, + } + notify(playerID, { mode = takeMode, takerName = takerName, sourceName = sourceName, transferred = transferred, stunned = 0, delayed = delayed, total = total, category = takeDelayCategory, delaySeconds = takeDelaySeconds }) + end + end + end + end + + Spring.SetGameRulesParam("isTakeInProgress", 0) + + if numTargets == 0 then + Spring.SendMessageToPlayer(playerID, "Nothing to take: no inactive allied teams") + end +end + +-- Auto-grant: once a delayed take's timer expires, hand the held-back units to +-- the player who first ran /take, without requiring a second /take. Cancel if +-- the source team regained an active player, or drop it if the original taker +-- is gone (left/died). +local function resolveExpiredTake(otherTeamID, pending) + if hasActivePlayer(otherTeamID) then + pendingDelayedTakes[otherTeamID] = nil + return + end + if not hasActivePlayer(pending.takerTeamID) then + pendingDelayedTakes[otherTeamID] = nil + return + end + + Spring.SetGameRulesParam("isTakeInProgress", 1) + local units = Spring.GetTeamUnits(otherTeamID) + local transferred = #units + for _, unitID in ipairs(units) do + Spring.TransferUnit(unitID, pending.takerTeamID, true) + end + transferResources(otherTeamID, pending.takerTeamID) + Spring.SetGameRulesParam("isTakeInProgress", 0) + + pendingDelayedTakes[otherTeamID] = nil + + local _, leaderID = Spring.GetTeamInfo(pending.takerTeamID, false) + if leaderID then + notify(leaderID, { mode = takeMode, takerName = getPlayerName(leaderID), sourceName = getTeamLeaderName(otherTeamID), transferred = transferred, stunned = 0, delayed = 0, total = transferred, category = takeDelayCategory, delaySeconds = takeDelaySeconds, isSecondPass = true }) + end +end + +function gadget:GameFrame(frame) + for otherTeamID, pending in pairs(pendingDelayedTakes) do + if frame >= pending.expiryFrame then + resolveExpiredTake(otherTeamID, pending) + end + end +end + +function gadget:RecvLuaMsg(msg, playerID) + if msg == TAKE_MSG then + ExecuteTake(playerID) + return true + end +end diff --git a/luarules/gadgets/game_disable_assist_ally.lua b/luarules/gadgets/game_allied_assist_mode.lua similarity index 88% rename from luarules/gadgets/game_disable_assist_ally.lua rename to luarules/gadgets/game_allied_assist_mode.lua index 17535cad71d..124171291c9 100644 --- a/luarules/gadgets/game_disable_assist_ally.lua +++ b/luarules/gadgets/game_allied_assist_mode.lua @@ -1,14 +1,18 @@ local gadget = gadget ---@type Gadget +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +local assistEnabled = Spring.GetModOptions()[ModeEnums.ModOptions.AlliedAssistMode] == ModeEnums.AlliedAssistMode.Enabled + function gadget:GetInfo() return { name = 'Disable Assist Ally Construction', - desc = 'Disable assisting allied units (e.g. labs and units/buildings under construction) when modoption is enabled', + desc = 'Disable assisting allied units (e.g. labs and units/buildings under construction) when modoption is disabled', author = 'Rimilel', date = 'April 2024', license = 'GNU GPL, v2 or later', - layer = 1, -- after unit_mex_upgrade_reclaimer and unit_geo_upgrade_reclaimer - enabled = Spring.GetModOptions().disable_assist_ally_construction, -- or Spring.GetModOptions().easytax, -- disabled for easytax and replaced with tax in game_tax_resource_sharing.lua + layer = 1, + enabled = not assistEnabled, } end @@ -16,8 +20,13 @@ if not gadgetHandler:IsSyncedCode() then return false end +if assistEnabled then + return false +end + local spAreTeamsAllied = Spring.AreTeamsAllied local spGetUnitCurrentCommand = Spring.GetUnitCurrentCommand + local spGetUnitDefID = Spring.GetUnitDefID local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt local spGetUnitTeam = Spring.GetUnitTeam @@ -31,8 +40,6 @@ local MOVESTATE_ROAM = CMD.MOVESTATE_ROAM local footprintSize = Game.squareSize * Game.footprintScale --- Local state - local builderMoveStateCmdDesc = { params = { 1, "Hold pos", "Maneuver", --[["Roam"]] }, } @@ -40,7 +47,7 @@ local builderMoveStateCmdDesc = { local gaiaTeam = Spring.GetGaiaTeamID() local isFactory = {} -local canBuildStep = {} -- i.e. anything that spends resources when assisted +local canBuildStep = {} for unitDefID, unitDef in ipairs(UnitDefs) do isFactory[unitDefID] = unitDef.isFactory canBuildStep[unitDefID] = unitDef.isFactory or (unitDef.isBuilder and (unitDef.canBuild or unitDef.canAssist)) @@ -48,8 +55,6 @@ end local checkUnitCommandList = {} -- Delay validating given units so the order of calls to UnitGiven does not matter. --- Local functions - local function removeRoamMoveState(unitID) local index = Spring.FindUnitCmdDesc(unitID, CMD_MOVESTATE) if index then @@ -103,8 +108,6 @@ local function validateCommands(unitID, unitTeam) end end --- Engine call-ins - function gadget:Initialize() gadgetHandler:RegisterAllowCommand(CMD_GUARD) gadgetHandler:RegisterAllowCommand(CMD_REPAIR) @@ -116,8 +119,7 @@ function gadget:UnitCreated(unitID, unitDefID, unitTeam, builderID) removeRoamMoveState(unitID) end - -- In unit_{xyz}_upgrade_reclaimer, units are transferred instantly, - -- so we can check immediately whether they are bypassing the rules: + -- upgrade-reclaimer gadgets transfer units instantly, so check immediately if builderID and isAlliedUnit(unitTeam, builderID) then checkUnitCommandList[unitID] = spGetUnitTeam(builderID) end @@ -136,7 +138,6 @@ function gadget:AllowCommand(unitID, unitDefID, unitTeam, cmdID, cmdParams, cmdO end function gadget:AllowUnitCreation(unitDefID, builderID, builderTeam, x, y, z, facing) - -- Identical blueprints placed on top of one another are converted to build assist. if builderID and not isFactory[spGetUnitDefID(builderID)] then local units = spGetUnitsInCylinder(x, z, footprintSize) for _, unitID in pairs(units) do @@ -145,6 +146,7 @@ function gadget:AllowUnitCreation(unitDefID, builderID, builderTeam, x, y, z, fa end end end + return true, true end @@ -164,7 +166,6 @@ local function _GameFramePost(unitList) end end function gadget:GameFramePost() - -- We rarely need to call this function: if next(checkUnitCommandList) then _GameFramePost(checkUnitCommandList) end diff --git a/luarules/gadgets/game_allied_unit_reclaim_mode.lua b/luarules/gadgets/game_allied_unit_reclaim_mode.lua new file mode 100644 index 00000000000..cccd5068720 --- /dev/null +++ b/luarules/gadgets/game_allied_unit_reclaim_mode.lua @@ -0,0 +1,73 @@ +local gadget = gadget ---@type Gadget + +function gadget:GetInfo() + return { + name = 'Allied Reclaim Control', + desc = 'Controls reclaiming allied units based on modoption', + author = 'Rimilel', + date = 'October 2025', + license = 'GNU GPL, v2 or later', + layer = 1, + enabled = true + } +end + +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +---------------------------------------------------------------- +-- Synced only +---------------------------------------------------------------- +if not gadgetHandler:IsSyncedCode() then + return false +end + +local reclaimEnabled = Spring.GetModOptions()[ModeEnums.ModOptions.AlliedUnitReclaimMode] == ModeEnums.AlliedUnitReclaimMode.Enabled +if reclaimEnabled then + return +end + +function gadget:Initialize() + gadgetHandler:RegisterAllowCommand(CMD.RECLAIM) + gadgetHandler:RegisterAllowCommand(CMD.GUARD) +end + +function gadget:AllowCommand(unitID, unitDefID, unitTeam, cmdID, cmdParams, cmdOptions, cmdTag, synced) + if (cmdID == CMD.RECLAIM and #cmdParams >= 1) then + local targetID = cmdParams[1] + local targetTeam + + if (targetID >= Game.maxUnits) then + return true + end + + targetTeam = Spring.GetUnitTeam(targetID) + if targetTeam == nil then + return true -- shouldn't happen; GetUnitTeam is nullable + end + + if unitTeam ~= targetTeam and Spring.AreTeamsAllied(unitTeam, targetTeam) then + return false + end + elseif (cmdID == CMD.GUARD and #cmdParams >= 1) then + local targetID = cmdParams[1] + + if (targetID >= Game.maxUnits) then + return true + end + + local targetTeam = Spring.GetUnitTeam(targetID) + if targetTeam == nil then + return true -- shouldn't happen; GetUnitTeam is nullable + end + + local targetUnitDef = UnitDefs[Spring.GetUnitDefID(targetID)] + + if unitTeam ~= targetTeam and Spring.AreTeamsAllied(unitTeam, targetTeam) then + -- labs count as canReclaim, so guarding them is blocked too + if targetUnitDef and targetUnitDef.canReclaim then + return false + end + end + end + return true +end diff --git a/luarules/gadgets/game_allow_partial_resurrection.lua b/luarules/gadgets/game_allow_partial_resurrection.lua new file mode 100644 index 00000000000..bccd44963c7 --- /dev/null +++ b/luarules/gadgets/game_allow_partial_resurrection.lua @@ -0,0 +1,35 @@ +local gadget = gadget ---@type Gadget + +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +local allowPartialResurrection = Spring.GetModOptions()[ModeEnums.ModOptions.AllowPartialResurrection] == ModeEnums.AllowPartialResurrection.Enabled + +function gadget:GetInfo() + return { + name = 'Allow Partial Resurrection', + desc = 'Controls whether partly reclaimed wrecks can be resurrected.', + author = 'RebelNode', + date = 'January 2026', + license = 'GNU GPL, v2 or later', + layer = 0, + enabled = not allowPartialResurrection + } +end + +if not gadgetHandler:IsSyncedCode() then + return false +end + +if allowPartialResurrection then + return false +end + +function gadget:AllowFeatureBuildStep(builderID, builderTeam, featureID, featureDefID, part) + if part < 0 then + local metal, defMetal = Spring.GetFeatureResources(featureID) + if metal == defMetal then + Spring.SetFeatureResurrect(featureID, false) + end + end + return true +end diff --git a/luarules/gadgets/game_disable_ally_geo_mex_upgrades.lua b/luarules/gadgets/game_disable_ally_geo_mex_upgrades.lua new file mode 100644 index 00000000000..eca77bd467b --- /dev/null +++ b/luarules/gadgets/game_disable_ally_geo_mex_upgrades.lua @@ -0,0 +1,78 @@ +local gadget = gadget ---@type Gadget + +function gadget:GetInfo() + return { + name = 'Disable ally extractor upgrade', + desc = 'Removes the ability for players to upgrade teammate mexes and geos in-place', + author = 'Hobo Joe', + date = 'August 2025', + license = 'GNU GPL, v2 or later', + layer = 1, + enabled = true + } +end + +if not gadgetHandler:IsSyncedCode() then + return false +end + +local UnitTransfer = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_synced.lua") +local TransferEnums = VFS.Include("common/luaUtilities/team_transfer/transfer_enums.lua") + +local mode = Spring.GetModOptions().unit_sharing_mode +local modeUnitTypes = UnitTransfer.GetModeUnitTypes(mode) +local enableShare = table.contains(modeUnitTypes, TransferEnums.UnitType.Utility) + +if enableShare then + return false +end + +local extractorRadius = Game.extractorRadius + +local spGetUnitsInCylinder = Spring.GetUnitsInCylinder +local spGetUnitDefID = Spring.GetUnitDefID +local spGetUnitTeam = Spring.GetUnitTeam + +local isMex = {} +local isGeo = {} +for unitDefID, unitDef in pairs(UnitDefs) do + if unitDef.extractsMetal > 0 then + isMex[unitDefID] = true + end + if unitDef.customParams.geothermal then + isGeo[unitDefID] = true + end +end + +local function mexBlocked(myTeam, x, y, z) + local units = spGetUnitsInCylinder(x, z, extractorRadius) + for _, unitID in ipairs(units) do + if isMex[spGetUnitDefID(unitID)] then + if spGetUnitTeam(unitID) ~= myTeam then + return true + end + end + end + return false +end + +local function geoBlocked(myTeam, x, y, z) + local units = spGetUnitsInCylinder(x, z, extractorRadius) + for _, unitID in ipairs(units) do + if isGeo[spGetUnitDefID(unitID)] then + if spGetUnitTeam(unitID) ~= myTeam then + return true + end + end + end + return false +end + +function gadget:AllowUnitCreation(unitDefID, builderID, builderTeam, x, y, z) + if isMex[unitDefID] then + return not mexBlocked(builderTeam, x, y, z) + elseif isGeo[unitDefID] then + return not geoBlocked(builderTeam, x, y, z) + end + return true +end diff --git a/luarules/gadgets/game_disable_unit_sharing.lua b/luarules/gadgets/game_disable_unit_sharing.lua deleted file mode 100644 index 8629fa5d24c..00000000000 --- a/luarules/gadgets/game_disable_unit_sharing.lua +++ /dev/null @@ -1,31 +0,0 @@ -local gadget = gadget ---@type Gadget - -function gadget:GetInfo() - return { - name = 'Disable Unit Sharing', - desc = 'Disable unit sharing when modoption is enabled', - author = 'Rimilel', - date = 'April 2024', - license = 'GNU GPL, v2 or later', - layer = 0, - enabled = true - } -end - ----------------------------------------------------------------- --- Synced only ----------------------------------------------------------------- -if not gadgetHandler:IsSyncedCode() then - return false -end - -if not Spring.GetModOptions().disable_unit_sharing then - return false -end - -function gadget:AllowUnitTransfer(unitID, unitDefID, fromTeamID, toTeamID, capture) - if (capture) then - return true - end - return false -end diff --git a/luarules/gadgets/game_energy_conversion.lua b/luarules/gadgets/game_energy_conversion.lua index c23059244aa..e9fbf473bbf 100644 --- a/luarules/gadgets/game_energy_conversion.lua +++ b/luarules/gadgets/game_energy_conversion.lua @@ -65,7 +65,6 @@ local paralysisRelRate = 75 -- unit HP / paralysisRelRate = paralysis dmg drop r local spGetPlayerInfo = Spring.GetPlayerInfo local spGetTeamRulesParam = Spring.GetTeamRulesParam local spSetTeamRulesParam = Spring.SetTeamRulesParam -local spGetTeamResources = Spring.GetTeamResources local spGetUnitHealth = Spring.GetUnitHealth local spGetUnitTeam = Spring.GetUnitTeam local spGetUnitDefID = Spring.GetUnitDefID @@ -313,7 +312,7 @@ function gadget:GameFrame(n) if tpos <= teamListCount then tID = teamList[tpos] - local eCur, eStor = spGetTeamResources(tID, 'energy') + local eCur, eStor = GG.GetTeamResources(tID, 'energy') local mmLevel = spGetTeamRulesParam(tID, mmLevelParamName) local convertAmount = eCur - eStor * mmLevel local eConverted, mConverted, teamUsages = 0, 0, 0 diff --git a/luarules/gadgets/game_no_share_to_enemy.lua b/luarules/gadgets/game_no_share_to_enemy.lua deleted file mode 100644 index d18cbcdd2a4..00000000000 --- a/luarules/gadgets/game_no_share_to_enemy.lua +++ /dev/null @@ -1,49 +0,0 @@ -local gadget = gadget ---@type Gadget - -function gadget:GetInfo() - return { - name = "game_no_share_to_enemy", - desc = "Disallows sharing to enemies", - author = "TheFatController", - date = "19 Jan 2008", - license = "GNU GPL, v2 or later", - layer = 0, - enabled = true - } -end - --------------------------------------------------------------------------------- --------------------------------------------------------------------------------- - -if not gadgetHandler:IsSyncedCode() then - return -end - -local AreTeamsAllied = Spring.AreTeamsAllied -local IsCheatingEnabled = Spring.IsCheatingEnabled - -local isNonPlayerTeam = { [Spring.GetGaiaTeamID()] = true } -local teams = Spring.GetTeamList() -for i=1,#teams do - local _,_,_,isAiTeam = Spring.GetTeamInfo(teams[i],false) - local isLuaAI = (Spring.GetTeamLuaAI(teams[i]) ~= nil) - if isAiTeam or isLuaAI then - isNonPlayerTeam[teams[i]] = true - end -end - -function gadget:AllowResourceTransfer(oldTeam, newTeam, type, amount) - if isNonPlayerTeam[oldTeam] or AreTeamsAllied(newTeam, oldTeam) or IsCheatingEnabled() then - return true - end - - return false -end - -function gadget:AllowUnitTransfer(unitID, unitDefID, oldTeam, newTeam, capture) - if isNonPlayerTeam[oldTeam] or AreTeamsAllied(newTeam, oldTeam) or capture or IsCheatingEnabled() then - return true - end - - return false -end \ No newline at end of file diff --git a/luarules/gadgets/game_prevent_excessive_share.lua b/luarules/gadgets/game_prevent_excessive_share.lua deleted file mode 100644 index 2b8a5885584..00000000000 --- a/luarules/gadgets/game_prevent_excessive_share.lua +++ /dev/null @@ -1,63 +0,0 @@ -local gadget = gadget ---@type Gadget - -function gadget:GetInfo() - return { - name = 'Prevent Excessive Share', - desc = 'Prevents sharing more resources or units than the receiver can hold', - author = 'Niobium', - date = 'April 2012', - license = 'GNU GPL, v2 or later', - layer = 2, -- after 'Tax Resource Sharing' - enabled = true - } -end - ----------------------------------------------------------------- --- Synced only ----------------------------------------------------------------- -if not gadgetHandler:IsSyncedCode() then - return false -end - -local spIsCheatingEnabled = Spring.IsCheatingEnabled -local spGetTeamUnitCount = Spring.GetTeamUnitCount - ----------------------------------------------------------------- --- Callins ----------------------------------------------------------------- -function gadget:AllowResourceTransfer(senderTeamId, receiverTeamId, resourceType, amount) - -- Spring uses 'm' and 'e' instead of the full names that we need, so we need to convert the resourceType - -- We also check for 'metal' or 'energy' incase Spring decides to use those in a later version - local resourceName - if (resourceType == 'm') or (resourceType == 'metal') then - resourceName = 'metal' - elseif (resourceType == 'e') or (resourceType == 'energy') then - resourceName = 'energy' - else - -- We don't handle whatever this resource is, allow it - return true - end - - -- Calculate the maximum amount the receiver can receive - local rCur, rStor, rPull, rInc, rExp, rShare = Spring.GetTeamResources(receiverTeamId, resourceName) - local maxShare = rStor * rShare - rCur - - -- Is the sender trying to send more than the maximum? Block it, possibly sending a reduced amount instead - if amount > maxShare then - if maxShare > 0 then - Spring.ShareTeamResource(senderTeamId, receiverTeamId, resourceName, maxShare) - end - return false - end - - -- Allow anything we don't explictly block - return true -end - -function gadget:AllowUnitTransfer(unitID, unitDefID, oldTeam, newTeam, capture) - local unitCount = spGetTeamUnitCount(newTeam) - if capture or spIsCheatingEnabled() or unitCount < Spring.GetTeamMaxUnits(newTeam) then - return true - end - return false -end diff --git a/luarules/gadgets/game_quick_start.lua b/luarules/gadgets/game_quick_start.lua index 664162866af..4bb8932a49a 100644 --- a/luarules/gadgets/game_quick_start.lua +++ b/luarules/gadgets/game_quick_start.lua @@ -627,6 +627,7 @@ local function initializeCommander(commanderID, teamID) local currentMetal = Spring.GetTeamResources(teamID, "metal") or 0 local currentEnergy = Spring.GetTeamResources(teamID, "energy") or 0 + local budget = (modOptions.override_quick_start_resources and modOptions.override_quick_start_resources > 0) and modOptions.override_quick_start_resources or quickStartAmountConfig[modOptions.quick_start_amount == "default" and "normal" or modOptions.quick_start_amount] local commanderX, commanderY, commanderZ = spGetUnitPosition(commanderID) if not commanderX or not commanderY or not commanderZ then @@ -906,7 +907,7 @@ function gadget:GameFrame(frame) if initialized and allDiscountsUsed and not running and allBuildsCompleted then for commanderID, comData in pairs(commanders) do if comData.budget and comData.budget > 0 then - Spring.AddTeamResource(comData.teamID, "metal", comData.budget) + GG.AddTeamResource(comData.teamID, "metal", comData.budget) end end gadgetHandler:RemoveGadget() diff --git a/luarules/gadgets/game_resource_transfer_controller.lua b/luarules/gadgets/game_resource_transfer_controller.lua new file mode 100644 index 00000000000..a9469d2ad96 --- /dev/null +++ b/luarules/gadgets/game_resource_transfer_controller.lua @@ -0,0 +1,357 @@ +local gadget = gadget ---@type Gadget + +function gadget:GetInfo() + if Game.nativeExcessSharing ~= false then + Spring.Echo("ERROR: Resource Transfer Controller requires nativeExcessSharing=false (Lua-owned resource sharing); economy GG API unavailable") + end + return { + name = "Resource Transfer Controller", + desc = "Controls allied resource sharing via Water-Fill on the gadget:ResourceExcess callin", + author = "Antigravity", + date = "2024", + license = "GPL-v2", + layer = -200, + enabled = Game.nativeExcessSharing == false, + } +end + +if not gadgetHandler:IsSyncedCode() then + return +end + +-- GG API defined before module imports so it survives module failure + +GG = GG or {} + +local TeamResourceData = VFS.Include("common/luaUtilities/team_transfer/team_resource_data.lua") +local ShareStats = VFS.Include("common/luaUtilities/economy/share_stats.lua") + +-- single place the GG economy boundary applies Lua-owned sent/received (engine no longer tracks them) +local function overlaySharing(teamID, resource, sent, received) + local s = ShareStats.Read(Spring, teamID, resource) + return s.sentRecent or sent, s.receivedRecent or received +end + +function GG.GetTeamResourceData(teamID, resource) + local d = TeamResourceData.Get(Spring, teamID, resource) + d.sent, d.received = overlaySharing(teamID, resource, d.sent, d.received) + return d +end + +function GG.GetTeamResources(teamID, resource) + local cur, stor, pull, inc, exp, share, sent, received = Spring.GetTeamResources(teamID, resource) + sent, received = overlaySharing(teamID, resource, sent, received) + return cur, stor, pull, inc, exp, share, sent, received +end + +function GG.AddTeamResource(teamID, resource, amount) + local current = Spring.GetTeamResources(teamID, resource) + return Spring.SetTeamResource(teamID, resource, current + amount) +end + +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") +local ContextFactoryModule = VFS.Include("common/luaUtilities/team_transfer/context_factory.lua") +local ResourceTransfer = VFS.Include("common/luaUtilities/team_transfer/resource_transfer_synced.lua") +local Shared = VFS.Include("common/luaUtilities/team_transfer/resource_transfer_shared.lua") +local Comms = VFS.Include("common/luaUtilities/team_transfer/resource_transfer_comms.lua") +local TechBlockingShared = VFS.Include("common/luaUtilities/team_transfer/tech_blocking_shared.lua") +local LuaRulesMsg = VFS.Include("common/luaUtilities/lua_rules_msg.lua") +local ManualShareLedger = VFS.Include("common/luaUtilities/economy/manual_share_ledger.lua") + +local WaterfillSolver = VFS.Include("common/luaUtilities/economy/economy_waterfill_solver.lua") + +local tracyAvailable = tracy and tracy.ZoneBeginN and tracy.ZoneEnd + +local modOptions = Spring.GetModOptions() + +local METAL = ResourceTypes.METAL +local ENERGY = ResourceTypes.ENERGY + +local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt +local spGetUnitTeam = Spring.GetUnitTeam +local spGetTeamResources = Spring.GetTeamResources +local spUseUnitResource = Spring.UseUnitResource +local spGetFeatureResources = Spring.GetFeatureResources +local spGetFeatureResurrect = Spring.GetFeatureResurrect +local spAreTeamsAllied = Spring.AreTeamsAllied +local spSetTeamResource = Spring.SetTeamResource +local spAddTeamResourceExcessStats = Spring.AddTeamResourceExcessStats +local spGetTeamInfo = Spring.GetTeamInfo +local spGetTeamList = Spring.GetTeamList +local spGetGameFrame = Spring.GetGameFrame + +local gaiaTeamID = Spring.GetGaiaTeamID() + +local springRepo = Spring +local contextFactory = ContextFactoryModule.create(springRepo) +local lastPolicyUpdate = 0 + +-- The engine fires gadget:ResourceExcess every frame, but redistribution runs on a +-- slower cadence (matches the native TEAM_SLOWUPDATE_RATE the gadget replaces). The +-- per-frame overflow is accumulated and folded into the snapshot on the cadence tick. +local CADENCE = 30 + +---@param teamID number Sender team ID +---@param targetTeamID number Receiver team ID +---@param resource string|ResourceName Resource type +---@param amount number Desired amount to transfer +---@return ResourceTransferResult +function GG.ShareTeamResource(teamID, targetTeamID, resource, amount) + local policyResult = Shared.GetCachedPolicyResult(teamID, targetTeamID, resource, springRepo) + local ctx = contextFactory.resourceTransfer(teamID, targetTeamID, resource, amount, policyResult) + local transferResult = ResourceTransfer.ResourceTransfer(ctx) + + if transferResult.success then + ManualShareLedger.Record(teamID, targetTeamID, transferResult.policyResult.resourceType, transferResult.sent, transferResult.received) + Comms.SendTransferChatMessages(transferResult, transferResult.policyResult) + end + + return transferResult +end + +---@param teamID number +---@param resource string|ResourceName +---@param level number +function GG.SetTeamShareLevel(teamID, resource, level) + -- Share level feeds the waterfill cursor (read live from GetTeamResources each + -- cadence tick) and the UI reads it directly; it is not a cached policy factor, + -- so no factor refresh is forced here. + Spring.SetTeamShareLevel(teamID, resource, level) +end + +---@param teamID number +---@param resource string|ResourceName +---@return number? +function GG.GetTeamShareLevel(teamID, resource) + local _, _, _, _, _, share = Spring.GetTeamResources(teamID, resource) + return share +end + +local function InitializeNewTeam(teamId) + -- Factors are per-team and independent, so a new/changed team only needs its own + -- record rewritten; GetCachedPolicyResult pairs it against every other team on read. + contextFactory.clearResourceCache() + local ctx = contextFactory.policy(teamId, teamId) + ResourceTransfer.CacheTeamFactor(Spring, teamId, ResourceTypes.METAL, ctx) + ResourceTransfer.CacheTeamFactor(Spring, teamId, ResourceTypes.ENERGY, ctx) +end + +function gadget:PlayerAdded(playerID) + local _, _, _, teamID = Spring.GetPlayerInfo(playerID, false) + if teamID then + InitializeNewTeam(teamID) + end +end + +-- Per-team overflow accumulator. The engine has already deducted this overflow from +-- the producer (clamped to storage) before the callin, so the solver re-injects it as +-- the snapshot's `excess` and the apply step hands it to recipients. +local overflowAccum = {} ---@type table + +-- Pooled snapshot entries so the cadence tick does not allocate per team per second. +local snapshotPool = {} ---@type table + +---Build the waterfill input from live engine state plus the accumulated overflow. +---@return table +local function buildSnapshot() + local teams = {} + local teamList = spGetTeamList() + for i = 1, #teamList do + local teamID = teamList[i] + if teamID ~= gaiaTeamID then + local _, _, isDead, _, _, allyTeam = spGetTeamInfo(teamID, false) + local acc = overflowAccum[teamID] + local mCur, mStor, _, _, _, mShare = spGetTeamResources(teamID, METAL) + local eCur, eStor, _, _, _, eShare = spGetTeamResources(teamID, ENERGY) + + local entry = snapshotPool[teamID] + if not entry then + entry = { metal = { resourceType = METAL }, energy = { resourceType = ENERGY } } + snapshotPool[teamID] = entry + end + entry.allyTeam = allyTeam + entry.isDead = isDead + + local m = entry.metal + m.current = mCur + m.storage = mStor + m.shareSlider = mShare + m.excess = acc and acc[1] or 0 + + local e = entry.energy + e.current = eCur + e.storage = eStor + e.shareSlider = eShare + e.excess = acc and acc[2] or 0 + + teams[teamID] = entry + end + end + return teams +end + +---Redistribute accumulated overflow + share-slider excess across allied teams, then +---refresh the policy factor cache. Runs on the cadence tick inside gadget:ResourceExcess. +---@param frame number +local function ProcessEconomy(frame) + if tracyAvailable then + tracy.ZoneBeginN("ResourceExcess_Cadence") + end + + local teams = buildSnapshot() + local results = ManualShareLedger.FoldInto(WaterfillSolver.SolveToResults(springRepo, teams)) + + -- The solver wrote each team's clamped post-share total back into the snapshot. + -- SetTeamResource moves the pools; AddTeamResourceExcessStats records only excess (the + -- engine's conservation ledger legitimately accounts for resource leaving the system). + -- sent/received are conserved transfers, tracked Lua-side via ShareStats below. + for i = 1, #results do + local r = results[i] + local team = teams[r.teamId] + local resData = team and team[r.resourceType] + if resData then + spSetTeamResource(r.teamId, r.resourceType, resData.current) + spAddTeamResourceExcessStats(r.teamId, r.resourceType, r.excess) + end + end + + ShareStats.Publish(springRepo, results) + + -- Overflow consumed this tick; clear so the next window starts from zero. + for _, acc in pairs(overflowAccum) do + acc[1] = 0 + acc[2] = 0 + end + + -- Policy factor refresh, folded into the same cadence tick and reading the + -- post-redistribution currents. updateRate 0 => always runs when called. + lastPolicyUpdate = ResourceTransfer.UpdatePolicyCache(springRepo, frame, lastPolicyUpdate, 0, contextFactory) + + if tracyAvailable then + tracy.ZoneEnd() + end +end + +---Synced, fires every frame for every team. excesses[teamID] = { [1]=metal, [2]=energy } +---overflow the engine has already deducted from the producer this frame. Returning true +---takes ownership so the engine does not native-buffer the overflow into resDelayedShare. +---@param excesses ResourceExcesses +---@return boolean handled +function gadget:ResourceExcess(excesses) + for teamID, pack in pairs(excesses) do + local acc = overflowAccum[teamID] + if not acc then + acc = { 0, 0 } + overflowAccum[teamID] = acc + end + acc[1] = acc[1] + (pack[1] or 0) + acc[2] = acc[2] + (pack[2] or 0) + end + + local frame = spGetGameFrame() + if frame % CADENCE == 0 then + ProcessEconomy(frame) + end + + return true +end + +function gadget:RecvLuaMsg(msg, playerID) + local params = LuaRulesMsg.ParseResourceShare(msg) + if params then + GG.ShareTeamResource(params.senderTeamID, params.targetTeamID, params.resourceType, params.amount) + return true + end + return false +end + +function gadget:Initialize() + if not spAddTeamResourceExcessStats then + Spring.Echo("ERROR: Resource Transfer Controller requires Spring.AddTeamResourceExcessStats (engine resource-excess-callin + excess-stats port); excess stats will be unavailable") + end + + local teamList = Spring.GetTeamList() + for _, senderTeamId in ipairs(teamList) do + InitializeNewTeam(senderTeamId) + end + lastPolicyUpdate = Spring.GetGameFrame() +end + +if TechBlockingShared.AnyTaxConfigured(modOptions) then + function gadget:AllowUnitBuildStep(builderID, builderTeam, unitID, unitDefID, part) + if part <= 0 then -- reclaiming + return true + end + + local beingBuilt = spGetUnitIsBeingBuilt(unitID) + if not beingBuilt then -- repair, not construction + return true + end + + local unitTeam = spGetUnitTeam(unitID) + if not unitTeam or builderTeam == unitTeam then + return true -- own unit, no tax + end + + if not spAreTeamsAllied(builderTeam, unitTeam) then + return true -- enemy, not taxable + end + + local unitDef = UnitDefs[unitDefID] + if not unitDef then + return true + end + + local taxRate = TechBlockingShared.GetTaxRate(builderTeam, modOptions) + if taxRate <= 0 then + return true -- no tax at this team's tech level + end + + local metalCost = unitDef.metalCost + local energyCost = unitDef.energyCost + local metalTax = metalCost * part * taxRate + local energyTax = energyCost * part * taxRate + local currentMetal = spGetTeamResources(builderTeam, "metal") + local currentEnergy = spGetTeamResources(builderTeam, "energy") + + if currentMetal < (metalTax + metalCost * part) or currentEnergy < (energyTax + energyCost * part) then + return false -- can't afford tax + end + + spUseUnitResource(builderID, "metal", metalTax) + spUseUnitResource(builderID, "energy", energyTax) + return true + end + + function gadget:AllowFeatureBuildStep(builderID, builderTeam, featureID, featureDefID, part) + if part < 0 then -- reclaiming + return true + end + + local resurrectUnitName = spGetFeatureResurrect(featureID) + if not resurrectUnitName or resurrectUnitName == "" then + return true -- not a resurrectable wreck + end + + -- Only tax during metal insertion phase (phase 1) + local featureMetal, featureMaxMetal = spGetFeatureResources(featureID) + if not featureMetal or featureMaxMetal <= 0 or featureMetal >= featureMaxMetal then + return true -- phase 2 (actual resurrection), no metal cost + end + + local taxRate = TechBlockingShared.GetTaxRate(builderTeam, modOptions) + if taxRate <= 0 then + return true -- no tax at this team's tech level + end + + local metalTax = featureMaxMetal * part * taxRate + local teamMetal = spGetTeamResources(builderTeam, "metal") + + if teamMetal < (metalTax + featureMaxMetal * part) then + return false -- can't afford tax + end + + spUseUnitResource(builderID, "metal", metalTax) + return true + end +end -- if AnyTaxConfigured diff --git a/luarules/gadgets/game_restrict_resurrection.lua b/luarules/gadgets/game_restrict_resurrection.lua deleted file mode 100644 index ac6140191b8..00000000000 --- a/luarules/gadgets/game_restrict_resurrection.lua +++ /dev/null @@ -1,31 +0,0 @@ -local gadget = gadget ---@type Gadget - -function gadget:GetInfo() - return { - name = 'Restrict unit resurrection', - desc = 'Disable resurrecting partly reclaimed wrecks when modoption enabled.', - author = 'RebelNode', - date = 'January 2026', - license = 'GNU GPL, v2 or later', - layer = 0, - enabled = false -- disabled for now and replaced with tax in game_tax_resource_sharing.lua, delete this gadget if decision not reverted later - } -end - -if not gadgetHandler:IsSyncedCode() then - return false -end - -if not Spring.GetModOptions().easytax then - return false -end - -function gadget:AllowFeatureBuildStep(builderID, builderTeam, featureID, featureDefID, part) - if part < 0 then -- We are reclaiming some wreck - metal, defMetal, _, _, _, _ = Spring.GetFeatureResources(featureID) - if metal == defMetal then -- It's the first time we are touching this wreck/feature to reclaim it, i.e. we don't need to call SetFeatureResurrect every frame once we already set the wreck as non-resurrectable. Is this check actually faster than just always calling it? - Spring.SetFeatureResurrect(featureID, false) -- Set the wreck as non-resurrectable - end - end - return true -end diff --git a/luarules/gadgets/game_restrict_unit_sharing.lua b/luarules/gadgets/game_restrict_unit_sharing.lua deleted file mode 100644 index da38796b386..00000000000 --- a/luarules/gadgets/game_restrict_unit_sharing.lua +++ /dev/null @@ -1,106 +0,0 @@ -local gadget = gadget ---@type Gadget - -function gadget:GetInfo() - return { - name = 'Restrict Unit Sharing', - desc = 'Stun/debuff economy and builder units when transferred to ally when modoption enabled.', - author = 'RebelNode', - date = 'January 2026', - license = 'GNU GPL, v2 or later', - layer = -2, -- before unit_healthbars_widget_forwarding so that AllowFeatureBuildStep will prevent reclaim bar from showing - enabled = true - } -end - -if not gadgetHandler:IsSyncedCode() then - return false -end - -if not Spring.GetModOptions().easytax then - return false -end - -local DEBUFF_FRAMES = Game.gameSpeed * 30 -local debuffedUnits = {} -- unitID -> { expireFrame, buildSpeed } - -local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt - --- gather all economy/builder units -local ecoUnits = {} -local builderUnits = {} -for unitDefID, unitDef in pairs(UnitDefs) do - local group = unitDef.customParams.unitgroup - if group then - if group == "builder" or group == "buildert2" or group == "buildert3" then - if not unitDef.isImmobile then -- not factory or conturret - builderUnits[unitDefID] = true - else - ecoUnits[unitDefID] = true - end - elseif group == "energy" or group == "metal" then - ecoUnits[unitDefID] = true - end - end -end - -function gadget:AllowUnitTransfer(unitID, unitDefID, fromTeamID, toTeamID, capture) - if (capture) and (not Spring.AreTeamsAllied(fromTeamID, toTeamID)) or fromTeamID == Spring.GetGaiaTeamID() or toTeamID == Spring.GetGaiaTeamID() then - return true - end - beingBuilt, buildProgress = spGetUnitIsBeingBuilt(unitID) - if beingBuilt and buildProgress > 0 and next(Spring.GetPlayerList(fromTeamID, true)) ~= nil then - return false -- Sharing partly built nanoframes is not allowed because letting it decay bypasses taxation and letting it build runs out the debuff early. Also if you can't assist ally build the unit could get stuck in factory. - end - if builderUnits[unitDefID] then - local unitDef = UnitDefs[unitDefID] - local startFrame = Spring.GetGameFrame() - local expireFrame = startFrame + DEBUFF_FRAMES - debuffedUnits[unitID] = { - expireFrame = expireFrame, - } - SendToUnsynced("unitBuildspeedDebuff", unitID, startFrame, expireFrame) - elseif ecoUnits[unitDefID] then - local _, maxHealth = Spring.GetUnitHealth(unitID) - Spring.AddUnitDamage(unitID, maxHealth * 5, 30) -- Stun for 30 seconds. - end - return true -end - -function gadget:AllowFeatureBuildStep(builderID, builderTeam, featureID, featureDefID, part) - if debuffedUnits[builderID] then - return false - end - return true -end - -function gadget:AllowUnitBuildStep(builderID, builderTeam, unitID, unitDefID, part) - if debuffedUnits[builderID] and spGetUnitIsBeingBuilt(unitID) then - return false - end - return true -end - -local expiredUnits = {} - -function gadget:GameFrame(n) - local expiredCount = 0 - for unitID, data in pairs(debuffedUnits) do - if n >= data.expireFrame then - expiredCount = expiredCount + 1 - expiredUnits[expiredCount] = unitID - end - end - for i = 1, expiredCount do - local unitID = expiredUnits[i] - expiredUnits[i] = nil - debuffedUnits[unitID] = nil - SendToUnsynced("unitBuildspeedDebuffEnd", unitID) - end -end - -function gadget:UnitDestroyed(unitID) - if debuffedUnits[unitID] then - debuffedUnits[unitID] = nil - SendToUnsynced("unitBuildspeedDebuffEnd", unitID) - end -end \ No newline at end of file diff --git a/luarules/gadgets/game_share_policy_forwarding.lua b/luarules/gadgets/game_share_policy_forwarding.lua new file mode 100644 index 00000000000..905891adbcb --- /dev/null +++ b/luarules/gadgets/game_share_policy_forwarding.lua @@ -0,0 +1,48 @@ +local gadget = gadget ---@type Gadget + +function gadget:GetInfo() + return { + name = 'Share Policy Forwarding', + desc = 'Forwards sharing-policy events from synced (team_transfer controllers) to LuaUI widgets.', + author = 'Attean', + date = 'June 2026', + license = 'GNU GPL, v2 or later', + layer = 0, + enabled = true + } +end + +if gadgetHandler:IsSyncedCode() then + return false +end + +local function sharePolicyChanged(_, teamId, domain) + if Script.LuaUI("SharePolicyChanged") then + Script.LuaUI.SharePolicyChanged(tonumber(teamId), domain) + end +end + +-- Per-unit manifestations of the constructor-build-delay policy. +local function unitBuildspeedDebuff(_, unitID, startFrame, expireFrame) + if Script.LuaUI("UnitBuildspeedDebuffHealthbars") then + Script.LuaUI.UnitBuildspeedDebuffHealthbars(unitID, startFrame, expireFrame) + end +end + +local function unitBuildspeedDebuffEnd(_, unitID) + if Script.LuaUI("UnitBuildspeedDebuffEndHealthbars") then + Script.LuaUI.UnitBuildspeedDebuffEndHealthbars(unitID) + end +end + +function gadget:Initialize() + gadgetHandler:AddSyncAction("SharePolicyChanged", sharePolicyChanged) + gadgetHandler:AddSyncAction("UnitBuildDelayStarted", unitBuildspeedDebuff) + gadgetHandler:AddSyncAction("UnitBuildDelayEnded", unitBuildspeedDebuffEnd) +end + +function gadget:ShutDown() + gadgetHandler:RemoveSyncAction("SharePolicyChanged") + gadgetHandler:RemoveSyncAction("UnitBuildDelayStarted") + gadgetHandler:RemoveSyncAction("UnitBuildDelayEnded") +end diff --git a/luarules/gadgets/game_tax_debuff_forwarding.lua b/luarules/gadgets/game_tax_debuff_forwarding.lua deleted file mode 100644 index 2ab9708f912..00000000000 --- a/luarules/gadgets/game_tax_debuff_forwarding.lua +++ /dev/null @@ -1,43 +0,0 @@ -local gadget = gadget ---@type Gadget - -function gadget:GetInfo() - return { - name = 'Tax Debuff Forwarding', - desc = 'Forwards buildspeed debuff events from synced to LuaUI for the easytax modoption.', - author = 'RebelNode', - date = 'March 2026', - license = 'GNU GPL, v2 or later', - layer = 0, - enabled = true - } -end - -if gadgetHandler:IsSyncedCode() then - return false -end - -if not Spring.GetModOptions().easytax then - return false -end - -local function unitBuildspeedDebuff(cmd, unitID, startFrame, expireFrame) - if Script.LuaUI("UnitBuildspeedDebuffHealthbars") then - Script.LuaUI.UnitBuildspeedDebuffHealthbars(unitID, startFrame, expireFrame) - end -end - -local function unitBuildspeedDebuffEnd(cmd, unitID) - if Script.LuaUI("UnitBuildspeedDebuffEndHealthbars") then - Script.LuaUI.UnitBuildspeedDebuffEndHealthbars(unitID) - end -end - -function gadget:Initialize() - gadgetHandler:AddSyncAction("unitBuildspeedDebuff", unitBuildspeedDebuff) - gadgetHandler:AddSyncAction("unitBuildspeedDebuffEnd", unitBuildspeedDebuffEnd) -end - -function gadget:ShutDown() - gadgetHandler:RemoveSyncAction("unitBuildspeedDebuff") - gadgetHandler:RemoveSyncAction("unitBuildspeedDebuffEnd") -end diff --git a/luarules/gadgets/game_tax_resource_sharing.lua b/luarules/gadgets/game_tax_resource_sharing.lua deleted file mode 100644 index 2699cb14011..00000000000 --- a/luarules/gadgets/game_tax_resource_sharing.lua +++ /dev/null @@ -1,271 +0,0 @@ -local gadget = gadget ---@type Gadget - -function gadget:GetInfo() - return { - name = 'Tax Resource Sharing', - desc = 'Tax Resource Sharing when modoption enabled. Modified from "Prevent Excessive Share" by Niobium', - author = 'Rimilel, RebelNode', - date = 'April 2024, January 2026', - license = 'GNU GPL, v2 or later', - layer = 1, -- Needs to occur before "Prevent Excessive Share" since their restriction on AllowResourceTransfer is not compatible - enabled = true - } -end - ----------------------------------------------------------------- --- Synced only ----------------------------------------------------------------- -if not gadgetHandler:IsSyncedCode() then - return false -end -if Spring.GetModOptions().tax_resource_sharing_amount == 0 and (not Spring.GetModOptions().easytax) then - return false -end - -local spIsCheatingEnabled = Spring.IsCheatingEnabled -local spGetTeamUnitCount = Spring.GetTeamUnitCount -local spGetTeamList = Spring.GetTeamList -local spGetTeamResources = Spring.GetTeamResources -local spGetTeamInfo = Spring.GetTeamInfo -local spAreTeamsAllied = Spring.AreTeamsAllied -local spUseTeamResource = Spring.UseTeamResource -local spUseUnitResource = Spring.UseUnitResource -local spShareTeamResource = Spring.ShareTeamResource -local spAddTeamResource = Spring.AddTeamResource -local spSetTeamResource = Spring.SetTeamResource -local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt -local spGetFeatureResources = Spring.GetFeatureResources -local spGetFeatureResurrect = Spring.GetFeatureResurrect -local spGetUnitTeam = Spring.GetUnitTeam -local math_max = math.max -local math_min = math.min - -local gameMaxUnits = math.min(Spring.GetModOptions().maxunits, math.floor(32000 / #Spring.GetTeamList())) - -local sharingTax = Spring.GetModOptions().tax_resource_sharing_amount -if Spring.GetModOptions().easytax then - sharingTax = 0.3 -- 30% tax for easytax modoption -end - -local function isAlliedUnit(teamID, unitID) - local unitTeam = Spring.GetUnitTeam(unitID) - return teamID and unitTeam and teamID ~= unitTeam and Spring.AreTeamsAllied(teamID, unitTeam) -end - ----------------------------------------------------------------- --- Callins ----------------------------------------------------------------- - -function gadget:AllowResourceTransfer(senderTeamId, receiverTeamId, resourceType, amount) - - -- Spring uses 'm' and 'e' instead of the full names that we need, so we need to convert the resourceType - -- We also check for 'metal' or 'energy' incase Spring decides to use those in a later version - local resourceName - if (resourceType == 'm') or (resourceType == 'metal') then - resourceName = 'metal' - elseif (resourceType == 'e') or (resourceType == 'energy') then - resourceName = 'energy' - else - -- We don't handle whatever this resource is, allow it - return true - end - - -- Calculate the maximum amount the receiver can receive - --Current, Storage, Pull, Income, Expense - local rCur, rStor, rPull, rInc, rExp, rShare = spGetTeamResources(receiverTeamId, resourceName) - - -- rShare is the share slider setting, don't exceed their share slider max when sharing - local maxShare = rStor * rShare - rCur - - if amount <= 0 or maxShare <= 0 then - return false - end - - local taxedAmount = math_min((1-sharingTax)*amount, maxShare) - local totalAmount = taxedAmount / (1-sharingTax) - local transferTax = totalAmount * sharingTax - - spSetTeamResource(receiverTeamId, resourceName, rCur+taxedAmount) - local sCur, _, _, _, _, _ = spGetTeamResources(senderTeamId, resourceName) - spSetTeamResource(senderTeamId, resourceName, sCur-totalAmount) - - -- Block the original transfer - return false -end - -function gadget:AllowUnitTransfer(unitID, unitDefID, oldTeam, newTeam, capture) - local unitCount = spGetTeamUnitCount(newTeam) - if capture or spIsCheatingEnabled() or unitCount < gameMaxUnits then - return true - end - return false -end - --- Below is implementation of the overflow mechanic in lua with taxing added. --- Using this requires disabling engine overflow mechanic completely via modrule "system.nativeExcessSharing = false". --- See https://github.com/beyond-all-reason/RecoilEngine/blob/master/rts/Sim/Misc/Team.cpp#L330 for engine overflow logic. --- team is the player who is overflowing, otherTeam are the allied players who receive resources -UPDATE_PERIOD = 30 -- probably don't try to change this, apparently it's 30 in engine Team.cpp -function gadget:GameFrame(f) - if (f-1) % UPDATE_PERIOD == 0 then - local teamList = spGetTeamList() - for j, teamID in ipairs(teamList) do - local teamEnergyCurrentLevel, teamEnergyStorage, teamEnergyPull, teamEnergyIncome, teamEnergyExpense, teamEnergyShare, teamEnergySent, teamEnergyReceived, teamEnergyExcess = spGetTeamResources(teamID, "energy") - local teamMetalCurrentLevel, teamMetalStorage, teamMetalPull, teamMetalIncome, teamMetalExpense, teamMetalShare, teamMetalSent, teamMetalReceived, teamMetalExcess = spGetTeamResources(teamID, "metal") - - local eShare = 0.0 - local mShare = 0.0 - - -- calculate the total amount of resources that all - -- allied teams can collectively receive through sharing - for i, otherTeamID in ipairs(teamList) do - local _,_,isDead = spGetTeamInfo(otherTeamID,false) - if otherTeamID ~= teamID and spAreTeamsAllied(teamID, otherTeamID) and (not isDead) then - local otherTeamMetalCurrentLevel, otherTeamMetalStorage,_,_,_, otherTeamMetalShare = spGetTeamResources(otherTeamID, "metal") - local otherTeamEnergyCurrentLevel, otherTeamEnergyStorage,_,_,_, otherTeamEnergyShare = spGetTeamResources(otherTeamID, "energy") - eShare = eShare + math_max(0.0, (otherTeamEnergyStorage * math_min(0.99, otherTeamEnergyShare)) - otherTeamEnergyCurrentLevel) - mShare = mShare + math_max(0.0,(otherTeamMetalStorage * math_min(0.99, otherTeamMetalShare)) - otherTeamMetalCurrentLevel) - end - end - - -- calculate how much we can share in total (resources above the red share slider) - local eExcess = math_max(0.0, teamEnergyCurrentLevel - (teamEnergyStorage * teamEnergyShare)) - local mExcess = math_max(0.0, teamMetalCurrentLevel - (teamMetalStorage * teamMetalShare)) - - local de = 0.0 - local dm = 0.0 - if eShare > 0.0 then - de = math_min(1.0, eExcess / eShare) - end - if mShare > 0.0 then - dm = math_min(1.0, mExcess / mShare) - end - - -- now evenly distribute our excess resources among allied teams - for i, otherTeamID in ipairs(teamList) do - local _,_,isDead = spGetTeamInfo(otherTeamID,false) - if otherTeamID ~= teamID and spAreTeamsAllied(teamID, otherTeamID) and (not isDead) then - local otherTeamMetalCurrentLevel, otherTeamMetalStorage,_,_,_, otherTeamMetalShare = spGetTeamResources(otherTeamID, "metal") - local otherTeamEnergyCurrentLevel, otherTeamEnergyStorage,_,_,_, otherTeamEnergyShare = spGetTeamResources(otherTeamID, "energy") - local edif = math_max(0.0, math_min(((otherTeamEnergyStorage * math_min(0.99, otherTeamEnergyShare)) - otherTeamEnergyCurrentLevel) * de, teamEnergyCurrentLevel)) - local mdif = math_max(0.0, math_min(((otherTeamMetalStorage * math_min(0.99, otherTeamMetalShare)) - otherTeamMetalCurrentLevel) * dm, teamMetalCurrentLevel)) - - -- Tax the resources here. These count as used resources for in statistics, not sure what they should count as. - spUseTeamResource(teamID, "energy", edif * sharingTax) - spUseTeamResource(teamID, "metal", mdif * sharingTax) - - spShareTeamResource(teamID, otherTeamID, "energy", edif * (1-sharingTax)) - spShareTeamResource(teamID, otherTeamID, "metal", mdif * (1-sharingTax)) - end - end - - ---------------------------------------------------------------- - -- The resources that we technically already wasted are added to allies if possible. This tries to do the same as resDelayedShare in engine. This lets players overflow reclaimed buildings etc. that go over their storage capacity. - ---------------------------------------------------------------- - -- Allies have already received some resources above, reduce the amount they can still receive accordingly. - eShare = eShare - eExcess - mShare = mShare - mExcess - - eExcess = math_max(0.0, teamEnergyExcess) - mExcess = math_max(0.0, teamMetalExcess) - - --- Tax the extra overflow, these resources are not shared and were already wasted to full storage - eExcess = math_max(0.0, eExcess * (1-sharingTax)) - mExcess = math_max(0.0, mExcess * (1-sharingTax)) - - de = 0.0 - dm = 0.0 - if eShare > 0.0 then - de = math_min(1.0, eExcess / eShare) - end - if mShare > 0.0 then - dm = math_min(1.0, mExcess / mShare) - end - - -- now evenly distribute our extra excess resources among allied teams - for i, otherTeamID in ipairs(teamList) do - local _,_,isDead = spGetTeamInfo(otherTeamID,false) - if otherTeamID ~= teamID and spAreTeamsAllied(teamID, otherTeamID) and (not isDead) then - local otherTeamMetalCurrentLevel, otherTeamMetalStorage,_,_,_, otherTeamMetalShare = spGetTeamResources(otherTeamID, "metal") - local otherTeamEnergyCurrentLevel, otherTeamEnergyStorage,_,_,_, otherTeamEnergyShare = spGetTeamResources(otherTeamID, "energy") - local edif = math_max(0.0, math_min(((otherTeamEnergyStorage * math_min(0.99, otherTeamEnergyShare)) - otherTeamEnergyCurrentLevel) * de, teamEnergyCurrentLevel)) - local mdif = math_max(0.0, math_min(((otherTeamMetalStorage * math_min(0.99, otherTeamMetalShare)) - otherTeamMetalCurrentLevel) * dm, teamMetalCurrentLevel)) - - -- These erroneously count as produced resources for allies in statistics. Not yet sure how to do this better, but this should be fine for modoption/testing at least. - spAddTeamResource(otherTeamID, "energy", edif) - spAddTeamResource(otherTeamID, "metal", mdif) - end - end - end - end -end - --- Tax inserting metal into wreck when resurrecting -function gadget:AllowFeatureBuildStep(builderID, builderTeam, featureID, featureDefID, part) - -- Only tax resurrection steps (positive part = resurrecting, negative = reclaiming) - if part < 0 then - return true - end - - local resurrectUnitName = spGetFeatureResurrect(featureID) - if not resurrectUnitName or resurrectUnitName == "" then - return true -- not a resurrectable wreck - end - - -- Only tax during phase 1 (metal insertion). Phase 2 is the actual resurrection which costs no metal. - local featureMetal, featureMaxMetal = spGetFeatureResources(featureID) - if not featureMetal or featureMaxMetal <= 0 or featureMetal >= featureMaxMetal then - return true - end - - local metalTax = featureMaxMetal * part * sharingTax - - local teamMetal = spGetTeamResources(builderTeam, "metal") - if teamMetal < (metalTax + featureMaxMetal * part) then - return false -- can't afford tax - end - - spUseUnitResource(builderID, "metal", metalTax) - - return true -end - --- Tax assisting ally buildprogress -function gadget:AllowUnitBuildStep(builderID, builderTeam, unitID, unitDefID, part) - if part < 0 then -- reclaiming - return true - end - - local beingBuilt = spGetUnitIsBeingBuilt(unitID) - if not beingBuilt then -- repair, not construction - return true - end - - -- Only tax when assisting other player's unit construction, not when building your own units - local unitTeam = spGetUnitTeam(unitID) - if not unitTeam or builderTeam == unitTeam then - return true - end - - local unitDef = UnitDefs[unitDefID] - if not unitDef then - return true - end - - -- Tax the builder team for resources consumed while assisting ally - local metalCost = unitDef.metalCost - local energyCost = unitDef.energyCost - - local metalTax = metalCost * part * sharingTax - local energyTax = energyCost * part * sharingTax - local currentMetal = spGetTeamResources(builderTeam, "metal") - local currentEnergy = spGetTeamResources(builderTeam, "energy") - if currentMetal < (metalTax + metalCost * part) or currentEnergy < (energyTax + energyCost * part) then - return false -- can't afford tax - end - - spUseUnitResource(builderID, "metal", metalTax) - spUseUnitResource(builderID, "energy", energyTax) - - return true -end diff --git a/luarules/gadgets/game_team_resources.lua b/luarules/gadgets/game_team_resources.lua index 06eb7a2d3ad..e9700aeff69 100644 --- a/luarules/gadgets/game_team_resources.lua +++ b/luarules/gadgets/game_team_resources.lua @@ -16,6 +16,8 @@ if not gadgetHandler:IsSyncedCode() then return false end +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") + local minStorageMetal = 1000 local minStorageEnergy = 1000 local mathMax = math.max @@ -59,19 +61,16 @@ local function setup(addResources) multiplier = teamPlayerCounts[teamID] or 1 -- Gaia has no players end - --If starting bonus multiplication is enabled, multiply it. local teamMultiplier = 1; if (bonusMultiplierEnabled) then teamMultiplier = select(7, Spring.GetTeamInfo(teamID)); end - -- Get starting resources and storage including any bonuses from mods local startingMetal = startMetal * teamMultiplier * multiplier local startingEnergy = startEnergy * teamMultiplier * multiplier local startingMetalStorage = startMetalStorage * teamMultiplier * multiplier local startingEnergyStorage = startEnergyStorage * teamMultiplier * multiplier - -- Get the player's start unit to make sure starting storage is no less than its storage local com = UnitDefs[Spring.GetTeamRulesParam(teamID, 'startUnit')] if com then commanderMinMetal = com.metalStorage or 0 @@ -100,6 +99,6 @@ function gadget:GameStart() end function gadget:TeamDied(teamID) - Spring.SetTeamShareLevel(teamID, 'metal', 0) - Spring.SetTeamShareLevel(teamID, 'energy', 0) + GG.SetTeamShareLevel(teamID, 'metal', 0) + GG.SetTeamShareLevel(teamID, 'energy', 0) end diff --git a/luarules/gadgets/game_unit_transfer_controller.lua b/luarules/gadgets/game_unit_transfer_controller.lua new file mode 100644 index 00000000000..53027d21634 --- /dev/null +++ b/luarules/gadgets/game_unit_transfer_controller.lua @@ -0,0 +1,274 @@ +---@class UnitTransferGadget : Gadget +---@field TeamShare fun(self, srcTeamID: number, dstTeamID: number) +local gadget = gadget ---@type UnitTransferGadget + +function gadget:GetInfo() + return { + name = 'Unit Transfer Controller', + desc = 'Controls unit ownership changes: sharing, takeovers, AllowUnitTransfer', + author = 'Rimilel, Attean, Antigravity', + date = 'April 2024', + license = 'GNU GPL, v2 or later', + layer = -200, + enabled = true + } +end + +---------------------------------------------------------------- +-- Synced only +---------------------------------------------------------------- +if not gadgetHandler:IsSyncedCode() then + return false +end + +local TransferEnums = VFS.Include("common/luaUtilities/team_transfer/transfer_enums.lua") +local ContextFactoryModule = VFS.Include("common/luaUtilities/team_transfer/context_factory.lua") +local Shared = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_shared.lua") +local UnitTransfer = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_synced.lua") +local UnitSharingCategories = VFS.Include("common/luaUtilities/team_transfer/unit_sharing_categories.lua") +local LuaRulesMsg = VFS.Include("common/luaUtilities/lua_rules_msg.lua") +local PolicyEvents = VFS.Include("common/luaUtilities/team_transfer/policy_events.lua") + +local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt + +-- mobile builders get buildspeed-0 debuff instead of stun + +local debuffedUnits = {} -- unitID -> expireFrame + +local mobileBuilderDefs = {} +for unitDefID, unitDef in pairs(UnitDefs) do + if UnitSharingCategories.isMobileBuilderDef(unitDef) then + mobileBuilderDefs[unitDefID] = true + end +end + +local function applyBuildDelay(unitID, unitDefID, stunSeconds) + local startFrame = Spring.GetGameFrame() + local expireFrame = startFrame + (stunSeconds * Game.gameSpeed) + debuffedUnits[unitID] = expireFrame + PolicyEvents.NotifyBuildDelay(unitID, startFrame, expireFrame) +end + +local function shouldStunUnit(unitDefID, stunCategory) + if not stunCategory then + return false + end + return Shared.IsShareableDef(unitDefID, stunCategory, UnitDefs) +end + +local function applyStun(unitID, unitDefID, policyResult) + -- Mobile builders get the build delay debuff instead of stun when enabled. + -- Independent of the eco-building stun: applies whenever the delay is set. + local buildDelaySeconds = tonumber(policyResult.buildDelaySeconds) or 0 + if buildDelaySeconds > 0 and mobileBuilderDefs[unitDefID] then + applyBuildDelay(unitID, unitDefID, buildDelaySeconds) + return + end + + local stunSeconds = tonumber(policyResult.stunSeconds) or 0 + if stunSeconds <= 0 then + return + end + + local stunCategory = policyResult.stunCategory + if not shouldStunUnit(unitDefID, stunCategory) then + return + end + local _, maxHealth = Spring.GetUnitHealth(unitID) + Spring.AddUnitDamage(unitID, maxHealth * 5, stunSeconds) +end + +-- engine contract via SetUnitTransferController: AllowUnitTransfer + TeamShare + +---@type SpringSynced +local springRepo = Spring +local contextFactory = ContextFactoryModule.create(springRepo) + +local POLICY_CACHE_UPDATE_RATE = 150 -- 5 seconds +local lastPolicyCacheUpdate = 0 + +GG = GG or {} + +local UnitTransferController = {} + +-- Factors are per-team and independent, so a new/changed team only needs its own record +-- rewritten; GetCachedPolicyResult pairs it against every other team on read. +---@param teamId number +local function InitializeNewTeam(teamId) + local ctx = contextFactory.policy(teamId, teamId) + UnitTransfer.CacheTeamFactor(springRepo, teamId, ctx) +end + +local function UpdatePolicyCache(frame) + if frame < lastPolicyCacheUpdate + POLICY_CACHE_UPDATE_RATE then + return + end + lastPolicyCacheUpdate = frame + + local teamList = springRepo.GetTeamList() or {} + for _, teamId in ipairs(teamList) do + InitializeNewTeam(teamId) + end +end + +---@param unitID number +---@param newTeamID number +---@param given boolean? +function GG.TransferUnit(unitID, newTeamID, given) + springRepo.TransferUnit(unitID, newTeamID, given or false) +end + +---@param unitIDs number[] +---@param newTeamID number +---@param given boolean? +---@return number transferred count of successfully transferred units +function GG.TransferUnits(unitIDs, newTeamID, given) + local transferred = 0 + for _, unitID in ipairs(unitIDs) do + local success = springRepo.TransferUnit(unitID, newTeamID, given or false) + if success then + transferred = transferred + 1 + end + end + return transferred +end + +---@param senderTeamID number +---@param targetTeamID number +---@param unitIDs number[] +---@return UnitTransferResult +function GG.ShareUnits(senderTeamID, targetTeamID, unitIDs) + local policyResult = Shared.GetCachedPolicyResult(senderTeamID, targetTeamID, springRepo) + local validation = Shared.ValidateUnits(policyResult, unitIDs, springRepo) + + if not validation or validation.status == TransferEnums.UnitValidationOutcome.Failure then + ---@type UnitTransferResult + return { + success = false, + outcome = validation and validation.status or TransferEnums.UnitValidationOutcome.Failure, + senderTeamId = senderTeamID, + receiverTeamId = targetTeamID, + validationResult = validation or { validUnitIds = {}, invalidUnitIds = unitIDs, status = TransferEnums.UnitValidationOutcome.Failure }, + policyResult = policyResult + } + end + + local transferCtx = contextFactory.unitTransfer(senderTeamID, targetTeamID, unitIDs, true, policyResult, validation) + local result = UnitTransfer.UnitTransfer(transferCtx) + + local outcome = result.outcome + if outcome == TransferEnums.UnitValidationOutcome.Success or outcome == TransferEnums.UnitValidationOutcome.PartialSuccess then + for _, unitID in ipairs(validation.validUnitIds) do + applyStun(unitID, springRepo.GetUnitDefID(unitID), policyResult) + end + Spring.SendLuaUIMsg("unit_transfer:success:" .. senderTeamID) + else + Spring.SendLuaUIMsg("unit_transfer:failed:" .. senderTeamID) + end + + return result +end + +---@param unitID number +---@param unitDefID number +---@param fromTeamID number +---@param toTeamID number +---@param capture boolean +---@return boolean +function UnitTransferController.AllowUnitTransfer(unitID, unitDefID, fromTeamID, toTeamID, capture) + if capture then + return true + end + + if Spring.GetGameRulesParam("isTakeInProgress") == 1 then + return true + end + + local policyResult = Shared.GetCachedPolicyResult(fromTeamID, toTeamID, springRepo) + + local validation = Shared.ValidateUnits(policyResult, { unitID }, springRepo) + + local allowed = validation and validation.status ~= TransferEnums.UnitValidationOutcome.Failure + + return allowed +end + +---@param srcTeamID number +---@param dstTeamID number +function UnitTransferController.TeamShare(srcTeamID, dstTeamID) + local units = springRepo.GetTeamUnits(srcTeamID) or {} + for _, unitID in ipairs(units) do + springRepo.TransferUnit(unitID, dstTeamID, true) + end +end + +function gadget:Initialize() + local teams = springRepo.GetTeamList() or {} + for _, teamId in ipairs(teams) do + InitializeNewTeam(teamId) + end + lastPolicyCacheUpdate = springRepo.GetGameFrame() + + if Spring.SetUnitTransferController then + ---@type GameUnitTransferController + local controller = { + AllowUnitTransfer = UnitTransferController.AllowUnitTransfer, + TeamShare = UnitTransferController.TeamShare + } + Spring.SetUnitTransferController(controller) + else + Spring.Echo("[UnitTransferController] WARNING: Spring.SetUnitTransferController not available - using gadget callins") + end +end + +function gadget:AllowUnitTransfer(unitID, unitDefID, fromTeamID, toTeamID, capture) + return UnitTransferController.AllowUnitTransfer(unitID, unitDefID, fromTeamID, toTeamID, capture) +end + +function gadget:TeamShare(srcTeamID, dstTeamID) + UnitTransferController.TeamShare(srcTeamID, dstTeamID) +end + +function gadget:RecvLuaMsg(msg, playerID) + local params = LuaRulesMsg.ParseUnitTransfer(msg) + if params then + local _, _, _, senderTeamID = springRepo.GetPlayerInfo(playerID, false) + if senderTeamID then + GG.ShareUnits(senderTeamID, params.targetTeamID, params.unitIDs) + end + return true + end + return false +end + +function gadget:GameFrame(frame) + UpdatePolicyCache(frame) + + for unitID, expireFrame in pairs(debuffedUnits) do + if frame >= expireFrame then + debuffedUnits[unitID] = nil + PolicyEvents.NotifyBuildDelayEnd(unitID) + end + end +end + +function gadget:UnitDestroyed(unitID) + if debuffedUnits[unitID] then + debuffedUnits[unitID] = nil + PolicyEvents.NotifyBuildDelayEnd(unitID) + end +end + +function gadget:AllowUnitBuildStep(builderID, builderTeam, unitID, unitDefID, part) + if debuffedUnits[builderID] and spGetUnitIsBeingBuilt(unitID) then + return false + end + return true +end + +function gadget:AllowFeatureBuildStep(builderID, builderTeam, featureID, featureDefID, part) + if debuffedUnits[builderID] then + return false + end + return true +end diff --git a/luarules/gadgets/unit_builder_priority.lua b/luarules/gadgets/unit_builder_priority.lua index 557ec94e303..e9381956c0b 100644 --- a/luarules/gadgets/unit_builder_priority.lua +++ b/luarules/gadgets/unit_builder_priority.lua @@ -65,7 +65,6 @@ local spInsertUnitCmdDesc = Spring.InsertUnitCmdDesc local spFindUnitCmdDesc = Spring.FindUnitCmdDesc local spGetUnitCmdDescs = Spring.GetUnitCmdDescs local spEditUnitCmdDesc = Spring.EditUnitCmdDesc -local spGetTeamResources = Spring.GetTeamResources local spGetTeamList = Spring.GetTeamList local spSetUnitRulesParam = Spring.SetUnitRulesParam local spGetUnitRulesParam = Spring.GetUnitRulesParam @@ -76,6 +75,7 @@ local spValidUnitID = Spring.ValidUnitID local spGetUnitTeam = Spring.GetUnitTeam local spGetAllUnits = Spring.GetAllUnits local spGetUnitDefID = Spring.GetUnitDefID +local spGetTeamResources = Spring.GetTeamResources local simSpeed = Game.gameSpeed local mathMax = math.max @@ -314,11 +314,13 @@ local function UpdatePassiveBuilders(teamID, interval, mCur, mStor, mInc, mShare -- calculate how much expense passive cons will be allowed (using pre-fetched resource data) local intervalOverSpeed = interval / simSpeed - local mStorEff = mStor * mShare - local teamStallingMetal = mCur - mathMax(mInc*stallMarginInc, mStorEff*stallMarginSto) - 1 + (interval)*(nonPassiveConsTotalExpenseMetal+mInc+mRec-mSent)/simSpeed + cur, stor, _, inc, _, share, sent, rec = GG.GetTeamResources(teamID, "metal") + stor = stor * share + local teamStallingMetal = cur - mathMax(inc*stallMarginInc, stor*stallMarginSto) - 1 + (interval)*(nonPassiveConsTotalExpenseMetal+inc+rec-sent)/simSpeed - local eStorEff = eStor * eShare - local teamStallingEnergy = eCur - mathMax(eInc*stallMarginInc, eStorEff*stallMarginSto) - 1 + (interval)*(nonPassiveConsTotalExpenseEnergy+eInc+eRec-eSent)/simSpeed + cur, stor, _, inc, _, share, sent, rec = GG.GetTeamResources(teamID, "energy") + stor = stor * share + local teamStallingEnergy = cur - mathMax(inc*stallMarginInc, stor*stallMarginSto) - 1 + (interval)*(nonPassiveConsTotalExpenseEnergy+inc+rec-sent)/simSpeed -- work through passive cons allocating as much expense as we have left for builderID in pairs(passiveTeamCons) do diff --git a/luarules/gadgets/unit_carrier_spawner.lua b/luarules/gadgets/unit_carrier_spawner.lua index 73c2f2b1122..767d21ce922 100644 --- a/luarules/gadgets/unit_carrier_spawner.lua +++ b/luarules/gadgets/unit_carrier_spawner.lua @@ -25,7 +25,6 @@ local spGetUnitPosition = Spring.GetUnitPosition local SetUnitNoSelect = Spring.SetUnitNoSelect local spGetUnitRulesParam = Spring.GetUnitRulesParam local spUseTeamResource = Spring.UseTeamResource -local spGetTeamResources = Spring.GetTeamResources local GetUnitCommands = Spring.GetUnitCommands local spSetUnitArmored = Spring.SetUnitArmored local spGetUnitStates = Spring.GetUnitStates @@ -542,8 +541,8 @@ local function spawnUnit(spawnData) stockpiledEnergy = stockpiledEnergy - energyCost end else - local availableMetal = spGetTeamResources(spawnData.teamID, "metal") - local availableEnergy = spGetTeamResources(spawnData.teamID, "energy") + local availableMetal = GG.GetTeamResources(spawnData.teamID, "metal") + local availableEnergy = GG.GetTeamResources(spawnData.teamID, "energy") if availableMetal > metalCost and availableEnergy > energyCost then spUseTeamResource(spawnData.teamID, "metal", metalCost) spUseTeamResource(spawnData.teamID, "energy", energyCost) diff --git a/luarules/gadgets/unit_cheat_no_waste.lua b/luarules/gadgets/unit_cheat_no_waste.lua index 901a490c23a..c6e6006fb71 100644 --- a/luarules/gadgets/unit_cheat_no_waste.lua +++ b/luarules/gadgets/unit_cheat_no_waste.lua @@ -47,7 +47,6 @@ local isAllyTeamWinning local averageAlliedTechGuesstimate --localized functions -local spGetTeamResources = Spring.GetTeamResources local spSetUnitBuildSpeed = Spring.SetUnitBuildSpeed for id, def in pairs(UnitDefs) do @@ -70,7 +69,7 @@ local function updateTeamOverflowing(allyID, oldMultiplier) local wastingMetal = true for teamID, _ in pairs(teamIDs) do - local metal, metalStorage, pull, metalIncome, metalExpense,share, metalSent, metalReceived = spGetTeamResources(teamID, "metal") + local metal, metalStorage, pull, metalIncome, metalExpense, share, metalSent, metalReceived = GG.GetTeamResources(teamID, "metal") totalMetal = totalMetal + metal totalMetalStorage = totalMetalStorage + metalStorage totalMetalReceived = totalMetalReceived + metalReceived diff --git a/luarules/gadgets/unit_geo_upgrade_reclaimer.lua b/luarules/gadgets/unit_geo_upgrade_reclaimer.lua index 1816777e489..acc663d9abc 100644 --- a/luarules/gadgets/unit_geo_upgrade_reclaimer.lua +++ b/luarules/gadgets/unit_geo_upgrade_reclaimer.lua @@ -77,7 +77,7 @@ function gadget:UnitFinished(unitID, unitDefID, unitTeam) if geo then local geoTeamID = Spring.GetUnitTeam(geo) Spring.DestroyUnit(geo, false, true) - Spring.AddTeamResource(unitTeam, "metal", isGeo[Spring.GetUnitDefID(geo)]) + GG.AddTeamResource(unitTeam, "metal", isGeo[Spring.GetUnitDefID(geo)]) if not transferInstantly and geoTeamID ~= unitTeam and not select(3, Spring.GetTeamInfo(geoTeamID, false)) then _G.transferredUnits[unitID] = Spring.GetGameFrame() Spring.TransferUnit(unitID, geoTeamID) diff --git a/luarules/gadgets/unit_mex_upgrade_reclaimer.lua b/luarules/gadgets/unit_mex_upgrade_reclaimer.lua index 3a0aa30a5d0..385879090c5 100644 --- a/luarules/gadgets/unit_mex_upgrade_reclaimer.lua +++ b/luarules/gadgets/unit_mex_upgrade_reclaimer.lua @@ -96,7 +96,7 @@ function gadget:UnitFinished(unitID, unitDefID, unitTeam) if mex then local mexTeamID = Spring.GetUnitTeam(mex) Spring.DestroyUnit(mex, false, true) - Spring.AddTeamResource(unitTeam, "metal", isMex[Spring.GetUnitDefID(mex)]) + GG.AddTeamResource(unitTeam, "metal", isMex[Spring.GetUnitDefID(mex)]) if not transferInstantly and mexTeamID ~= unitTeam and not select(3, Spring.GetTeamInfo(mexTeamID, false)) then _G.transferredUnits[unitID] = Spring.GetGameFrame() Spring.TransferUnit(unitID, mexTeamID) diff --git a/luarules/gadgets/unit_prevent_share_load.lua b/luarules/gadgets/unit_prevent_share_load.lua index dbab2f0ecb1..1da385e8600 100644 --- a/luarules/gadgets/unit_prevent_share_load.lua +++ b/luarules/gadgets/unit_prevent_share_load.lua @@ -18,6 +18,6 @@ if not gadgetHandler:IsSyncedCode() then end function gadget:AllowUnitTransfer(unitID, unitDefID, oldTeam, newTeam, capture) - Spring.GiveOrderToUnit(unitID, CMD.REMOVE, { CMD.LOAD_UNITS }, { "alt" }) - return true + Spring.GiveOrderToUnit(unitID, CMD.REMOVE, { CMD.LOAD_UNITS }, { "alt" }) + return true end diff --git a/luarules/gadgets/unit_prevent_share_self_d.lua b/luarules/gadgets/unit_prevent_share_self_d.lua index de25aa013a0..1f020e71802 100644 --- a/luarules/gadgets/unit_prevent_share_self_d.lua +++ b/luarules/gadgets/unit_prevent_share_self_d.lua @@ -21,10 +21,10 @@ local monitorPlayers = {} local spGetPlayerInfo = Spring.GetPlayerInfo function gadget:AllowUnitTransfer(unitID, unitDefID, oldTeam, newTeam, capture) - if Spring.GetUnitSelfDTime(unitID) > 0 then - Spring.GiveOrderToUnit(unitID, CMD.SELFD, {}, 0) - end - return true + if Spring.GetUnitSelfDTime(unitID) > 0 then + Spring.GiveOrderToUnit(unitID, CMD.SELFD, {}, 0) + end + return true end local function removeSelfdOrders(teamID) diff --git a/luarules/gadgets/unit_scenario_loadout.lua b/luarules/gadgets/unit_scenario_loadout.lua index 2f9ea6263f2..484a53714a4 100644 --- a/luarules/gadgets/unit_scenario_loadout.lua +++ b/luarules/gadgets/unit_scenario_loadout.lua @@ -16,6 +16,8 @@ if not gadgetHandler:IsSyncedCode() then return end +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") + local nanoturretunitIDs = {} local loadoutcomplete = false @@ -126,10 +128,10 @@ function gadget:GameFrame(n) end if next(additionalStorage) then for teamID, additionalstorage in pairs(additionalStorage) do - local m, mstore = Spring.GetTeamResources(teamID, "metal") - local e, estore = Spring.GetTeamResources(teamID, "energy") - Spring.SetTeamResource(teamID, 'ms', mstore + additionalstorage.metal) - Spring.SetTeamResource(teamID, 'es', estore + additionalstorage.energy) + local m, mstore = GG.GetTeamResources(teamID, "metal") + local e, estore = GG.GetTeamResources(teamID, "energy") + Spring.SetTeamResource(teamID, "ms", mstore + additionalstorage.metal) + Spring.SetTeamResource(teamID, "es", estore + additionalstorage.energy) end additionalStorage = nil end diff --git a/luarules/gadgets/unit_zombies.lua b/luarules/gadgets/unit_zombies.lua index ecf8ae0b48b..3d1d7697e36 100644 --- a/luarules/gadgets/unit_zombies.lua +++ b/luarules/gadgets/unit_zombies.lua @@ -14,6 +14,8 @@ if not gadgetHandler:IsSyncedCode() then return false end +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") + local modOptions = Spring.GetModOptions() local ZOMBIE_GUARD_RADIUS = 500 -- Radius for zombies to guard allies @@ -108,7 +110,6 @@ local spDestroyFeature = Spring.DestroyFeature local spGetUnitIsDead = Spring.GetUnitIsDead local spGiveOrderArrayToUnit = Spring.GiveOrderArrayToUnit local spGetUnitsInCylinder = Spring.GetUnitsInCylinder -local spSetTeamResource = Spring.SetTeamResource local spGetUnitHealth = Spring.GetUnitHealth local spSetUnitHealth = Spring.SetUnitHealth local spSetUnitRulesParam = Spring.SetUnitRulesParam @@ -299,14 +300,14 @@ local function setGaiaStorage() local metalStorageToSet = 1000000 local energyStorageToSet = 1000000 - local _, currentMetalStorage = Spring.GetTeamResources(gaiaTeamID, "metal") + local _, currentMetalStorage = GG.GetTeamResources(gaiaTeamID, "metal") if currentMetalStorage and currentMetalStorage < metalStorageToSet then - spSetTeamResource(gaiaTeamID, "ms", metalStorageToSet) + Spring.SetTeamResource(gaiaTeamID, "ms", metalStorageToSet) end - local _, currentEnergyStorage = Spring.GetTeamResources(gaiaTeamID, "energy") + local _, currentEnergyStorage = GG.GetTeamResources(gaiaTeamID, "energy") if currentEnergyStorage and currentEnergyStorage < energyStorageToSet then - spSetTeamResource(gaiaTeamID, "es", energyStorageToSet) + Spring.SetTeamResource(gaiaTeamID, "es", energyStorageToSet) end end @@ -728,8 +729,8 @@ function gadget:GameFrame(frame) end if frame % ZOMBIE_CHECK_INTERVAL == 0 then - Spring.AddTeamResource(gaiaTeamID, "metal", 1000000) - Spring.AddTeamResource(gaiaTeamID, "energy", 1000000) + GG.AddTeamResource(gaiaTeamID, "metal", 1000000) + GG.AddTeamResource(gaiaTeamID, "energy", 1000000) for featureID, featureData in pairs(corpsesData) do local featureX, featureY, featureZ = spGetFeaturePosition(featureID) if not featureX then --doesn't exist anymore diff --git a/luaui/Tests/team_transfer/test_resource_transfer.lua b/luaui/Tests/team_transfer/test_resource_transfer.lua new file mode 100644 index 00000000000..d43e90ab880 --- /dev/null +++ b/luaui/Tests/team_transfer/test_resource_transfer.lua @@ -0,0 +1,82 @@ +---@diagnostic disable: lowercase-global, undefined-field + +local LuaRulesMsg = VFS.Include("common/luaUtilities/lua_rules_msg.lua") +local TransferEnums = VFS.Include("common/luaUtilities/team_transfer/transfer_enums.lua") + +local function GetAlliedTargetTeamID(myTeamID) + local teamList = Spring.GetTeamList() + for _, teamID in ipairs(teamList) do + if teamID ~= myTeamID and Spring.AreTeamsAllied(myTeamID, teamID) then + return teamID + end + end + return nil +end + +function skip() + return Spring.GetGameFrame() <= 0 +end + +function setup() + Test.clearMap() + + assert(Game.nativeExcessSharing == false, + "this test requires Lua-owned resource sharing (Game.nativeExcessSharing must be false)") + + local team0 = Spring.GetMyTeamID() + local team1 = GetAlliedTargetTeamID(team0) + assert(team1 ~= nil, "expected at least one allied target team for transfer test") + local metal = TransferEnums.ResourceType.METAL + local energy = TransferEnums.ResourceType.ENERGY + + SyncedRun(function(locals) + Spring.SetTeamResource(locals.team0, "ms", 5000) + Spring.SetTeamResource(locals.team1, "ms", 5000) + Spring.SetTeamResource(locals.team0, "es", 5000) + Spring.SetTeamResource(locals.team1, "es", 5000) + + Spring.SetTeamResource(locals.team0, locals.metal, 1000) + Spring.SetTeamResource(locals.team0, locals.energy, 1000) + Spring.SetTeamResource(locals.team1, locals.metal, 0) + Spring.SetTeamResource(locals.team1, locals.energy, 0) + end, 60) + + -- policy cache refreshes on the redistribution cadence (every 30 frames); wait two cycles + Test.waitFrames(65) +end + +function cleanup() + Test.clearMap() +end + +function test() + local team0 = Spring.GetMyTeamID() + local team1 = GetAlliedTargetTeamID(team0) + assert(team1 ~= nil, "expected at least one allied target team for transfer test") + + local modOptions = Spring.GetModOptions() + local rse = modOptions.resource_sharing_enabled + assert(rse == "1" or rse == 1 or rse == true, + "startscript should set resource_sharing_enabled=1, got: " .. tostring(rse)) + + local metalBefore = Spring.GetTeamResources(team0, "metal") + assert(metalBefore and metalBefore > 0, "team0 should have metal to share") + + local metalReceivedBefore = Spring.GetTeamResources(team1, "metal") or 0 + + Spring.SendLuaRulesMsg(LuaRulesMsg.SerializeResourceShare(team0, team1, "metal", 100)) + Test.waitFrames(2) + + local metalReceivedAfter = Spring.GetTeamResources(team1, "metal") or 0 + assert(metalReceivedAfter > metalReceivedBefore, + "team1 metal should increase after share, before=" .. tostring(metalReceivedBefore) .. " after=" .. tostring(metalReceivedAfter)) + + local energyReceivedBefore = Spring.GetTeamResources(team1, "energy") or 0 + + Spring.SendLuaRulesMsg(LuaRulesMsg.SerializeResourceShare(team0, team1, "energy", 100)) + Test.waitFrames(2) + + local energyReceivedAfter = Spring.GetTeamResources(team1, "energy") or 0 + assert(energyReceivedAfter > energyReceivedBefore, + "team1 energy should increase after share, before=" .. tostring(energyReceivedBefore) .. " after=" .. tostring(energyReceivedAfter)) +end diff --git a/luaui/Tests/team_transfer/test_unit_transfer_disabled.lua b/luaui/Tests/team_transfer/test_unit_transfer_disabled.lua new file mode 100644 index 00000000000..21c0546d15e --- /dev/null +++ b/luaui/Tests/team_transfer/test_unit_transfer_disabled.lua @@ -0,0 +1,70 @@ +---@diagnostic disable: lowercase-global, undefined-field, duplicate-set-field + +local UnitTransferUnsynced = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_unsynced.lua") + +local function GetAlliedTargetTeamID(myTeamID) + local teamList = Spring.GetTeamList() + for _, teamID in ipairs(teamList) do + if teamID ~= myTeamID and Spring.AreTeamsAllied(myTeamID, teamID) then + return teamID + end + end + return nil +end + +function skip() + return Spring.GetGameFrame() <= 0 +end + +function setup() + Test.clearMap() + + SyncedRun(function() + _G._origGetModOptions = _G._origGetModOptions or Spring.GetModOptions + Spring.GetModOptions = function() + local opts = _G._origGetModOptions() + opts.unit_sharing_mode = "none" + return opts + end + end, 60) + + -- Wait for the unit transfer controller's policy cache to refresh (every 150 frames) + Test.waitFrames(160) +end + +function cleanup() + SyncedRun(function() + if _G._origGetModOptions then + Spring.GetModOptions = _G._origGetModOptions + _G._origGetModOptions = nil + end + end, 60) + + -- Let the cache refresh back to the real mod options + Test.waitFrames(160) + + Test.clearMap() +end + +function test() + local team0 = Spring.GetMyTeamID() + local team1 = GetAlliedTargetTeamID(team0) + assert(team1 ~= nil, "expected at least one allied target team for transfer test") + + local unitID = SyncedRun(function(locals) + local x, z = Game.mapSizeX / 2, Game.mapSizeZ / 2 + local y = Spring.GetGroundHeight(x, z) + return Spring.CreateUnit("armpw", x, y, z, "south", locals.team0) + end, 60) + + assert(unitID, "unit should have been created") + assert(Spring.GetUnitTeam(unitID) == team0, "unit should start on team0") + + Spring.SelectUnitArray({unitID}) + UnitTransferUnsynced.ShareUnits(team1) + + Test.waitFrames(10) + + assert(Spring.GetUnitTeam(unitID) == team0, + "unit should still belong to team0 when sharing is disabled, got team: " .. tostring(Spring.GetUnitTeam(unitID))) +end diff --git a/luaui/Tests/team_transfer/test_unit_transfer_enabled.lua b/luaui/Tests/team_transfer/test_unit_transfer_enabled.lua new file mode 100644 index 00000000000..cd5dad3b7bc --- /dev/null +++ b/luaui/Tests/team_transfer/test_unit_transfer_enabled.lua @@ -0,0 +1,54 @@ +---@diagnostic disable: lowercase-global, undefined-field + +local UnitTransferUnsynced = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_unsynced.lua") + +local function GetAlliedTargetTeamID(myTeamID) + local teamList = Spring.GetTeamList() + for _, teamID in ipairs(teamList) do + if teamID ~= myTeamID and Spring.AreTeamsAllied(myTeamID, teamID) then + return teamID + end + end + return nil +end + +function skip() + return Spring.GetGameFrame() <= 0 +end + +function setup() + Test.clearMap() +end + +function cleanup() + Test.clearMap() +end + +function test() + local team0 = Spring.GetMyTeamID() + local team1 = GetAlliedTargetTeamID(team0) + assert(team1 ~= nil, "expected at least one allied target team for transfer test") + + local modOptions = Spring.GetModOptions() + assert(modOptions.unit_sharing_mode == "all", + "startscript should set unit_sharing_mode=all, got: " .. tostring(modOptions.unit_sharing_mode)) + assert(modOptions.take_mode == "enabled", + "startscript should set take_mode=enabled, got: " .. tostring(modOptions.take_mode)) + + local unitID = SyncedRun(function(locals) + local x, z = Game.mapSizeX / 2, Game.mapSizeZ / 2 + local y = Spring.GetGroundHeight(x, z) + return Spring.CreateUnit("armpw", x, y, z, "south", locals.team0) + end, 60) + + assert(unitID, "unit should have been created") + assert(Spring.GetUnitTeam(unitID) == team0, "unit should start on team0") + + Spring.SelectUnitArray({unitID}) + UnitTransferUnsynced.ShareUnits(team1) + + Test.waitFrames(10) + + assert(Spring.GetUnitTeam(unitID) == team1, + "unit should belong to team1 after sharing, got team: " .. tostring(Spring.GetUnitTeam(unitID))) +end diff --git a/sounds/voice/config.lua b/sounds/voice/config.lua index 0d8cd5b5048..1ec1823bbc9 100644 --- a/sounds/voice/config.lua +++ b/sounds/voice/config.lua @@ -462,6 +462,12 @@ return { Tech4TeamReached = { delay = 9999999, }, + Tech2TeamLost = { + delay = 9999999, + }, + Tech3TeamLost = { + delay = 9999999, + }, -- Units Detected ["UnitDetected/Tech2UnitDetected"] = { diff --git a/spec/common/luaUtilities/economy/share_stats_spec.lua b/spec/common/luaUtilities/economy/share_stats_spec.lua new file mode 100644 index 00000000000..5e940b6b0b4 --- /dev/null +++ b/spec/common/luaUtilities/economy/share_stats_spec.lua @@ -0,0 +1,63 @@ +local ShareStats = VFS.Include("common/luaUtilities/economy/share_stats.lua") +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") + +local METAL = ResourceTypes.METAL +local ENERGY = ResourceTypes.ENERGY + +-- minimal rules-param backed Spring: the only surface ShareStats touches +local function fakeSpring() + local store = {} + return { + SetTeamRulesParam = function(teamID, key, value) + store[teamID] = store[teamID] or {} + store[teamID][key] = value + end, + GetTeamRulesParam = function(teamID, key) + return store[teamID] and store[teamID][key] or nil + end, + } +end + +describe("ShareStats", function() + it("publishes net per-team sent/received and reads them back", function() + local spring = fakeSpring() + ShareStats.Publish(spring, { + { teamId = 0, resourceType = METAL, sent = 100, received = 0, excess = 0 }, + { teamId = 1, resourceType = METAL, sent = 0, received = 90, excess = 0 }, + }) + assert.is_near(100, ShareStats.Read(spring, 0, METAL).sent, 1e-6) + assert.is_near(90, ShareStats.Read(spring, 1, METAL).received, 1e-6) + -- recent send/received mirror the last tick (top bar + GG.GetTeamResources overlay) + assert.is_near(100, ShareStats.Read(spring, 0, METAL).sentRecent, 1e-6) + assert.is_near(90, ShareStats.Read(spring, 1, METAL).receivedRecent, 1e-6) + end) + + it("accumulates cumulative totals across ticks while recent tracks only the latest", function() + local spring = fakeSpring() + local tick = { { teamId = 0, resourceType = METAL, sent = 10, received = 0 } } + ShareStats.Publish(spring, tick) + ShareStats.Publish(spring, tick) + ShareStats.Publish(spring, tick) + assert.is_near(30, ShareStats.Read(spring, 0, METAL).sent, 1e-6) + assert.is_near(10, ShareStats.Read(spring, 0, METAL).sentRecent, 1e-6) + end) + + it("returns nil before anything is published, so callers can fall back to engine values", function() + local spring = fakeSpring() + local s = ShareStats.Read(spring, 0, METAL) + assert.is_nil(s.sent) + assert.is_nil(s.received) + assert.is_nil(s.sentRecent) + end) + + it("keeps metal and energy independent", function() + local spring = fakeSpring() + ShareStats.Publish(spring, { + { teamId = 0, resourceType = METAL, sent = 5, received = 0 }, + { teamId = 0, resourceType = ENERGY, sent = 0, received = 7 }, + }) + assert.is_near(5, ShareStats.Read(spring, 0, METAL).sent, 1e-6) + assert.is_near(7, ShareStats.Read(spring, 0, ENERGY).received, 1e-6) + assert.is_near(0, ShareStats.Read(spring, 0, METAL).received, 1e-6) + end) +end) diff --git a/spec/common/luaUtilities/team_transfer/policy_events_spec.lua b/spec/common/luaUtilities/team_transfer/policy_events_spec.lua index fcc8cdbec17..14671d69e77 100644 --- a/spec/common/luaUtilities/team_transfer/policy_events_spec.lua +++ b/spec/common/luaUtilities/team_transfer/policy_events_spec.lua @@ -50,3 +50,17 @@ describe("PolicyEvents.NotifyIfChanged", function() assert.are.equal(5, sent[1].teamId) end) end) + +describe("PolicyEvents build-delay debuff", function() + it("forwards NotifyBuildDelay with unit and frame window", function() + local args + PolicyEvents.NotifyBuildDelay(42, 100, 190, function(...) args = { ... } end) + assert.are.same({ "UnitBuildDelayStarted", 42, 100, 190 }, args) + end) + + it("forwards NotifyBuildDelayEnd with the unit", function() + local args + PolicyEvents.NotifyBuildDelayEnd(42, function(...) args = { ... } end) + assert.are.same({ "UnitBuildDelayEnded", 42 }, args) + end) +end) diff --git a/spec/common/luaUtilities/team_transfer/take_comms_spec.lua b/spec/common/luaUtilities/team_transfer/take_comms_spec.lua new file mode 100644 index 00000000000..e8c8a4100e7 --- /dev/null +++ b/spec/common/luaUtilities/team_transfer/take_comms_spec.lua @@ -0,0 +1,22 @@ +local TakeComms = VFS.Include("common/luaUtilities/team_transfer/take_comms.lua") +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") + +describe("TakeComms.GetPolicy", function() + it("reads mode, delaySeconds and delayCategory from modoptions", function() + local policy = TakeComms.GetPolicy({ + [ModeEnums.ModOptions.TakeMode] = ModeEnums.TakeMode.StunDelay, + [ModeEnums.ModOptions.TakeDelaySeconds] = 15, + [ModeEnums.ModOptions.TakeDelayCategory] = ModeEnums.UnitCategory.Combat, + }) + assert.equal(ModeEnums.TakeMode.StunDelay, policy.mode) + assert.equal(15, policy.delaySeconds) + assert.equal(ModeEnums.UnitCategory.Combat, policy.delayCategory) + end) + + it("defaults to enabled / 30s / resource when unset", function() + local policy = TakeComms.GetPolicy({}) + assert.equal(ModeEnums.TakeMode.Enabled, policy.mode) + assert.equal(30, policy.delaySeconds) + assert.equal(ModeEnums.UnitCategory.Resource, policy.delayCategory) + end) +end) diff --git a/spec/common/luaUtilities/team_transfer/unit_sharing_categories_spec.lua b/spec/common/luaUtilities/team_transfer/unit_sharing_categories_spec.lua index ecf560027ed..c777da4cac4 100644 --- a/spec/common/luaUtilities/team_transfer/unit_sharing_categories_spec.lua +++ b/spec/common/luaUtilities/team_transfer/unit_sharing_categories_spec.lua @@ -114,6 +114,28 @@ describe("unit_sharing_categories #categories", function() end) end) + describe("isMobileBuilderDef", function() + it("should return true for mobile builders", function() + assert.is_true(Categories.isMobileBuilderDef({ isBuilder = true })) + end) + + it("should return false for immobile builders (nano/con turrets)", function() + assert.is_false(Categories.isMobileBuilderDef({ isBuilder = true, isImmobile = true })) + end) + + it("should return false for assist-only turrets that are not builders", function() + assert.is_false(Categories.isMobileBuilderDef({ canAssist = true })) + end) + + it("should return false for factories", function() + assert.is_false(Categories.isMobileBuilderDef({ isBuilder = true, isFactory = true })) + end) + + it("should return false for non-builders", function() + assert.is_false(Categories.isMobileBuilderDef({ customParams = { unitgroup = "weapon" } })) + end) + end) + describe("isCombatGroupBuilderDef", function() it("should return true for the listed fast T2 engineers", function() for _, name in ipairs({ "corfast", "armfark", "armconsul" }) do diff --git a/spec/common/luaUtilities/team_transfer/unit_transfer_synced_spec.lua b/spec/common/luaUtilities/team_transfer/unit_transfer_synced_spec.lua index fd8cb0f40e1..9c03911f011 100644 --- a/spec/common/luaUtilities/team_transfer/unit_transfer_synced_spec.lua +++ b/spec/common/luaUtilities/team_transfer/unit_transfer_synced_spec.lua @@ -538,10 +538,11 @@ describe("Easy Tax stun mechanics #stun", function() [1] = { id = 1, name = "mockfusion", customParams = { unitgroup = "energy" } }, [2] = { id = 2, name = "mockcon", canAssist = true, buildOptions = {}, customParams = { techlevel = "1" } }, [3] = { id = 3, name = "mockpawn", customParams = { unitgroup = "weapon" }, weapons = { "gun" } }, + [4] = { id = 4, name = "mockbuilder", isBuilder = true, customParams = { techlevel = "1" } }, } describe("GetPolicy", function() - it("should expose stunSeconds and stunCategory from Easy Tax config", function() + it("should expose stunSeconds, stunCategory and buildDelaySeconds from Easy Tax config", function() local api = spring:Build() api.GetModOptions = function() return modeModOpts(easyTaxMode) end @@ -550,9 +551,10 @@ describe("Easy Tax stun mechanics #stun", function() assert.equal(30, policy.stunSeconds) assert.equal(ModeEnums.UnitFilterCategory.Resource, policy.stunCategory) + assert.equal(30, policy.buildDelaySeconds) end) - it("should default stunSeconds to 0 when not configured", function() + it("should default stunSeconds and buildDelaySeconds to 0 when not configured", function() local api = spring:Build() api.GetModOptions = function() return { unit_sharing_mode = "all" } end @@ -560,6 +562,7 @@ describe("Easy Tax stun mechanics #stun", function() local policy = UnitTransfer.GetPolicy(ctx) assert.equal(0, policy.stunSeconds) + assert.equal(0, policy.buildDelaySeconds) end) end) @@ -693,4 +696,78 @@ describe("Easy Tax stun mechanics #stun", function() assert.equal(1, result.invalidUnitCount) end) end) + + describe("ValidateUnits build-delay tally", function() + it("counts mobile builders among the valid units", function() + local builderId, fusionId = 910, 911 + sender.units = {} + sender.units[builderId] = { unitDefId = 4, beingBuilt = false, buildProgress = 1.0 } + sender.units[fusionId] = { unitDefId = 1, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 0, + } + + local result = UnitTransfer.ValidateUnits(policy, { builderId, fusionId }, api, mockDefs) + assert.equal(2, result.validUnitCount) + assert.equal(1, result.buildDelayedUnitCount) + end) + + it("counts zero when the selection has no mobile builders", function() + local fusionId = 912 + sender.units = {} + sender.units[fusionId] = { unitDefId = 1, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 0, + } + + local result = UnitTransfer.ValidateUnits(policy, { fusionId }, api, mockDefs) + assert.equal(0, result.buildDelayedUnitCount) + end) + end) + + describe("ValidateUnits stun tally", function() + it("counts stun-category units among the valid units", function() + local fusionId, builderId = 920, 921 + sender.units = {} + sender.units[fusionId] = { unitDefId = 1, beingBuilt = false, buildProgress = 1.0 } + sender.units[builderId] = { unitDefId = 4, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { fusionId, builderId }, api, mockDefs) + assert.equal(2, result.validUnitCount) + assert.equal(1, result.stunnedUnitCount) + end) + + it("counts zero when the selection has no stun-category units", function() + local builderId = 922 + sender.units = {} + sender.units[builderId] = { unitDefId = 4, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { builderId }, api, mockDefs) + assert.equal(0, result.stunnedUnitCount) + end) + end) end) diff --git a/spec/luarules/gadgets/game_resource_transfer_controller_spec.lua b/spec/luarules/gadgets/game_resource_transfer_controller_spec.lua new file mode 100644 index 00000000000..220b514ad34 --- /dev/null +++ b/spec/luarules/gadgets/game_resource_transfer_controller_spec.lua @@ -0,0 +1,237 @@ +local Builders = VFS.Include("spec/builders/index.lua") +local ModeEnums = VFS.Include("modes/sharing_mode_enums.lua") +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") +local SharedConfig = VFS.Include("common/luaUtilities/economy/shared_config.lua") +local WaterfillSolver = VFS.Include("common/luaUtilities/economy/economy_waterfill_solver.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 buildTeamsTable(builders) + local teams = {} + for i = 1, #builders do + local built = builders[i]:Build() + teams[built.id] = built + end + return teams +end + +local function buildSpring(opts, teams) + local builder = Builders.Spring.new() + builder:WithModOption(ModeEnums.ModOptions.TaxResourceSharingAmount, opts.taxRate or 0) + for i = 1, #teams do + builder:WithTeam(teams[i]) + end + allyAll(teams, builder) + return builder:Build() +end + +describe("WaterfillSolver.SolveToResults", function() + before_each(function() + SharedConfig.resetCache() + end) + + it("returns EconomyTeamResult[] with correct structure", function() + local teamA = Builders.Team:new() + :WithMetal(800) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + local teamB = Builders.Team:new() + :WithMetal(200) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + + normalizeAllies({ teamA, teamB }, teamA.allyTeam) + + local spring = buildSpring({ taxRate = 0 }, { teamA, teamB }) + local teamsList = buildTeamsTable({ teamA, teamB }) + + local results = WaterfillSolver.SolveToResults(spring, teamsList) + + assert.is_table(results) + assert.is_true(#results >= 2) + + local foundMetal = false + for _, result in ipairs(results) do + assert.is_number(result.teamId) + assert.is_not_nil(result.resourceType) + assert.is_number(result.delta) + assert.is_number(result.excess) + assert.is_number(result.sent) + assert.is_number(result.received) + if result.resourceType == ResourceTypes.METAL then + foundMetal = true + end + end + assert.is_true(foundMetal) + end) + + it("balances metal between teams without tax", function() + local teamA = Builders.Team:new() + :WithMetal(800) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + local teamB = Builders.Team:new() + :WithMetal(200) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + + normalizeAllies({ teamA, teamB }, teamA.allyTeam) + + local spring = buildSpring({ taxRate = 0 }, { teamA, teamB }) + local teamsList = buildTeamsTable({ teamA, teamB }) + + local results = WaterfillSolver.SolveToResults(spring, teamsList) + + local teamAMetal, teamBMetal + for _, result in ipairs(results) do + if result.resourceType == ResourceTypes.METAL then + if result.teamId == teamA.id then + teamAMetal = result + elseif result.teamId == teamB.id then + teamBMetal = result + end + end + end + + assert.is_near(-300, teamAMetal.delta, 0.1) + assert.is_near(300, teamBMetal.delta, 0.1) + assert.is_near(300, teamAMetal.sent, 0.1) + assert.is_near(300, teamBMetal.received, 0.1) + assert.is_near(0, teamAMetal.delta + teamBMetal.delta, 0.1) + end) + + it("applies tax correctly in results", 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 = buildTeamsTable({ teamA, teamB }) + + local results = WaterfillSolver.SolveToResults(spring, teamsList) + + local teamAMetal, teamBMetal + for _, result in ipairs(results) do + if result.resourceType == ResourceTypes.METAL then + if result.teamId == teamA.id then + teamAMetal = result + elseif result.teamId == teamB.id then + teamBMetal = result + end + end + end + + assert.is_near(-66.67, teamAMetal.delta, 0.1) + assert.is_near(33.33, teamBMetal.delta, 0.1) + assert.is_true(teamAMetal.sent > teamBMetal.received) + -- the tax burn is the net loss across the group + assert.is_near(-33.33, teamAMetal.delta + teamBMetal.delta, 0.1) + end) +end) + +describe("ResourceExcess redistribution", function() + before_each(function() + SharedConfig.resetCache() + end) + + it("processes excesses and returns results", function() + local teamA = Builders.Team:new() + :WithMetal(800) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + :WithEnergy(500) + :WithEnergyStorage(1000) + :WithEnergyShareSlider(50) + local teamB = Builders.Team:new() + :WithMetal(200) + :WithMetalStorage(1000) + :WithMetalShareSlider(50) + :WithEnergy(500) + :WithEnergyStorage(1000) + :WithEnergyShareSlider(50) + + normalizeAllies({ teamA, teamB }, teamA.allyTeam) + + local spring = buildSpring({ taxRate = 0 }, { teamA, teamB }) + local teamsList = buildTeamsTable({ teamA, teamB }) + + local results = WaterfillSolver.SolveToResults(spring, teamsList) + + assert.is_table(results) + assert.is_true(#results >= 4) + + local metalResults = 0 + local energyResults = 0 + for _, result in ipairs(results) do + if result.resourceType == ResourceTypes.METAL then + metalResults = metalResults + 1 + elseif result.resourceType == ResourceTypes.ENERGY then + energyResults = energyResults + 1 + end + end + + assert.equal(2, metalResults) + assert.equal(2, energyResults) + end) +end) + +describe("ManualShareLedger", function() + local ManualShareLedger = VFS.Include("common/luaUtilities/economy/manual_share_ledger.lua") + + before_each(function() + ManualShareLedger.Clear() + end) + + local function makeResults() + return { + { teamId = 0, resourceType = ResourceTypes.METAL, delta = 0, sent = 10, received = 0, excess = 0 }, + { teamId = 1, resourceType = ResourceTypes.METAL, delta = 0, sent = 0, received = 5, excess = 0 }, + } + end + + it("folds recorded transfers into result entries", function() + ManualShareLedger.Record(0, 1, ResourceTypes.METAL, 100, 70) + ManualShareLedger.Record(0, 1, ResourceTypes.METAL, 50, 35) + + local results = ManualShareLedger.FoldInto(makeResults()) + + assert.is_near(160, results[1].sent, 1e-6) + assert.is_near(0, results[1].received, 1e-6) + assert.is_near(5 + 105, results[2].received, 1e-6) + end) + + it("clears folded amounts so the next tick gets none", function() + ManualShareLedger.Record(0, 1, ResourceTypes.METAL, 100, 70) + ManualShareLedger.FoldInto(makeResults()) + + local results = ManualShareLedger.FoldInto(makeResults()) + assert.is_near(10, results[1].sent, 1e-6) + assert.is_near(5, results[2].received, 1e-6) + end) + + it("does not touch deltas", function() + ManualShareLedger.Record(0, 1, ResourceTypes.METAL, 100, 70) + local results = ManualShareLedger.FoldInto(makeResults()) + assert.equal(0, results[1].delta) + assert.equal(0, results[2].delta) + end) +end) diff --git a/tools/headless_testing/startscript.txt b/tools/headless_testing/startscript.txt index f392ed1955e..04c7eb4a9a2 100644 --- a/tools/headless_testing/startscript.txt +++ b/tools/headless_testing/startscript.txt @@ -12,6 +12,12 @@ { debugcommands=1:cheat|2:godmode|3:globallos|30:runtestsheadless; deathmode=neverend; + tech_blocking=1; + t2_tech_threshold=1; + t3_tech_threshold=2; + unit_sharing_mode=all; + resource_sharing_enabled=1; + take_mode=enabled; } [ALLYTEAM0] { @@ -21,8 +27,19 @@ allyteam=0; teamleader=0; } + [TEAM1] + { + allyteam=0; + teamleader=0; + } [PLAYER0] { team=0; } + [AI0] + { + team=1; + shortname=NullAI; + host=0; + } }