diff --git a/common/luaUtilities/economy/share_stats.lua b/common/luaUtilities/economy/share_stats.lua new file mode 100644 index 00000000000..8eb45301651 --- /dev/null +++ b/common/luaUtilities/economy/share_stats.lua @@ -0,0 +1,57 @@ +-- Lua-side sent/received sharing stats via team rules params (engine keeps only excess; sent/received are conserved, tracked Lua-side per RecoilEngine#3032) + +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 sent/received + last tick's send (top bar overflow indicator) +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..8e20b3afc8c --- /dev/null +++ b/common/luaUtilities/team_transfer/gui_advplayerlist/validation.lua @@ -0,0 +1,56 @@ +--- Unit validation helpers for advplayerslist.lua +--- partition depends only on the sender's modes, so memoise once per selection, not per player +local UnitShared = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_shared.lua") + +local UnitValidationHelpers = {} + +-- The selection these memos describe (nil when nothing is selected). +local currentSelection = nil +-- Memoised ValidateUnits result for a shareable (canShare) receiver under currentSelection. +local sharedPartition = nil +-- Memoised trivial result for a non-shareable receiver (ValidateUnits short-circuits). +local deniedResult = nil +-- separate backing tables so ValidateUnits fills in place and both can be live in one draw pass +local sharedScratch = {} +local deniedScratch = {} + +---Record the active selection and drop the per-selection memos; partition computed lazily later. +---@param selectedUnits number[]? +function UnitValidationHelpers.SetSelection(selectedUnits) + currentSelection = (selectedUnits and #selectedUnits > 0) and selectedUnits or nil + sharedPartition = nil + deniedResult = nil +end + +---Drop the memos without touching the selection; used when our own sharing policy changes. +function UnitValidationHelpers.InvalidateValidations() + sharedPartition = nil + deniedResult = nil +end + +---Validate the current selection for a single receiver, memoised; nil when nothing selected. +---@param myTeamID number +---@param receiverTeamID number +---@return UnitValidationResult | nil +function UnitValidationHelpers.GetPlayerUnitValidation(myTeamID, receiverTeamID) + if not currentSelection then + return nil + end + + local policyResult = UnitShared.GetCachedPolicyResult(myTeamID, receiverTeamID, Spring) + if not policyResult.canShare then + -- denied receivers all short-circuit to the same empty partition, so compute once + if not deniedResult then + deniedResult = UnitShared.ValidateUnits(policyResult, currentSelection, Spring, nil, deniedScratch) + end + return deniedResult + end + + -- Identical for every shareable receiver (modes are the sender's), so compute once. + if not sharedPartition then + sharedPartition = UnitShared.ValidateUnits(policyResult, currentSelection, Spring, nil, sharedScratch) + end + return sharedPartition +end + +return UnitValidationHelpers 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 8a516eda96e..31637f2880d 100644 --- a/luarules/gadgets/cmd_dev_helpers.lua +++ b/luarules/gadgets/cmd_dev_helpers.lua @@ -633,8 +633,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 2529cfe519e..921cc319df5 100644 --- a/luarules/gadgets/cmd_give.lua +++ b/luarules/gadgets/cmd_give.lua @@ -55,7 +55,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 df103085fac..ca36feadad4 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 -- Cache prefix bytes for allocation-free hot-path check @@ -144,18 +177,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} @@ -166,20 +237,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 @@ -228,17 +357,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 @@ -309,6 +427,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 @@ -345,6 +467,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..288015bc889 --- /dev/null +++ b/luarules/gadgets/cmd_take.lua @@ -0,0 +1,225 @@ +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 held-back units when the delay timer expires; cancel if source regained a player or the taker left +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_restrict_resurrection.lua b/luarules/gadgets/game_allow_partial_resurrection.lua similarity index 63% rename from luarules/gadgets/game_restrict_resurrection.lua rename to luarules/gadgets/game_allow_partial_resurrection.lua index 5931128ef05..da98c7c8ce7 100644 --- a/luarules/gadgets/game_restrict_resurrection.lua +++ b/luarules/gadgets/game_allow_partial_resurrection.lua @@ -1,14 +1,18 @@ 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 = 'Restrict unit resurrection', - desc = 'Disable resurrecting partly reclaimed wrecks when modoption enabled.', + 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 = false -- disabled for now and replaced with tax in game_tax_resource_sharing.lua, delete this gadget if decision not reverted later + enabled = not allowPartialResurrection } end @@ -16,7 +20,7 @@ if not gadgetHandler:IsSyncedCode() then return false end -if not Spring.GetModOptions().easytax then +if allowPartialResurrection then return false 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 0b7f7bc0663..48a6649ce91 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..a5e8f6361c6 --- /dev/null +++ b/luarules/gadgets/game_resource_transfer_controller.lua @@ -0,0 +1,346 @@ +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 + +-- redistribution cadence (matches native TEAM_SLOWUPDATE_RATE); per-frame overflow accumulates between ticks +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 is read live (waterfill cursor + UI), not a cached factor, so no refresh forced + 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) + -- per-team factor; GetCachedPolicyResult pairs it against other teams 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; engine already deducted it, solver re-injects as snapshot excess +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)) + + -- SetTeamResource moves the pools; AddTeamResourceExcessStats records excess only; sent/received tracked Lua-side via ShareStats + 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 on same tick, reading post-redistribution currents (updateRate 0 = always) + 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_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..c8b4ab1ef85 --- /dev/null +++ b/luarules/gadgets/game_unit_transfer_controller.lua @@ -0,0 +1,278 @@ +---@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 + +-- reused scratch tables; separate ones because AllowUnitTransfer fires inside ShareUnits' loop (shared would clobber) +local shareValidationScratch = {} +local allowValidationScratch = {} +local allowUnitScratch = {} -- single-element {unitID} list, refilled per AllowUnitTransfer call + +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 build-delay debuff instead of stun, independent of eco-building stun + 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 = {} + +-- per-team factor; GetCachedPolicyResult pairs it against other teams 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, nil, shareValidationScratch) + + 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) + + allowUnitScratch[1] = unitID + local validation = Shared.ValidateUnits(policyResult, allowUnitScratch, springRepo, nil, allowValidationScratch) + + 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 a88532978bf..0dd48e745b0 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/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/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; + } }