diff --git a/common/luaUtilities/lua_rules_msg.lua b/common/luaUtilities/lua_rules_msg.lua new file mode 100644 index 00000000000..7139d71c0df --- /dev/null +++ b/common/luaUtilities/lua_rules_msg.lua @@ -0,0 +1,103 @@ +--- LuaRulesMsg wire format (widget↔gadget); keep serialization and parsing in one place + +local LuaRulesMsg = {} + +local RESOURCE_SHARE_PREFIX = "share:resource:" + +---Serialize a resource share request for SendLuaRulesMsg +---@param senderTeamID number +---@param targetTeamID number +---@param resourceType string +---@param amount number +---@return string +function LuaRulesMsg.SerializeResourceShare(senderTeamID, targetTeamID, resourceType, amount) + return RESOURCE_SHARE_PREFIX .. senderTeamID .. ":" .. targetTeamID .. ":" .. resourceType .. ":" .. amount +end + +---Parse a resource share message from RecvLuaMsg +---@param msg string +---@return ResourceShareParams|nil params nil if not a resource share message or invalid +function LuaRulesMsg.ParseResourceShare(msg) + if msg:sub(1, #RESOURCE_SHARE_PREFIX) ~= RESOURCE_SHARE_PREFIX then + return nil + end + + local parts = {} + for part in msg:gmatch("[^:]+") do + parts[#parts + 1] = part + end + + if #parts < 6 then + return nil + end + + local senderTeamID = tonumber(parts[3]) + local targetTeamID = tonumber(parts[4]) + local resourceType = parts[5] + local amount = tonumber(parts[6]) + + if not senderTeamID or not targetTeamID or not resourceType or not amount or amount <= 0 then + return nil + end + + return { + senderTeamID = senderTeamID, + targetTeamID = targetTeamID, + resourceType = resourceType, + amount = amount, + } +end + +local UNIT_TRANSFER_PREFIX = "share:units:" + +---@class UnitTransferParams +---@field targetTeamID number +---@field unitIDs number[] + +---Serialize a unit transfer request for SendLuaRulesMsg +---@param targetTeamID number +---@param unitIDs number[] +---@return string +function LuaRulesMsg.SerializeUnitTransfer(targetTeamID, unitIDs) + return UNIT_TRANSFER_PREFIX .. targetTeamID .. ":" .. table.concat(unitIDs, ",") +end + +---Parse a unit transfer message from RecvLuaMsg +---@param msg string +---@return UnitTransferParams|nil params nil if not a unit transfer message or invalid +function LuaRulesMsg.ParseUnitTransfer(msg) + if msg:sub(1, #UNIT_TRANSFER_PREFIX) ~= UNIT_TRANSFER_PREFIX then + return nil + end + + local rest = msg:sub(#UNIT_TRANSFER_PREFIX + 1) + local colonPos = rest:find(":") + if not colonPos then + return nil + end + + local targetTeamID = tonumber(rest:sub(1, colonPos - 1)) + if not targetTeamID then + return nil + end + + local unitIDsStr = rest:sub(colonPos + 1) + local unitIDs = {} + for idStr in unitIDsStr:gmatch("[^,]+") do + local id = tonumber(idStr) + if id then + unitIDs[#unitIDs + 1] = id + end + end + + if #unitIDs == 0 then + return nil + end + + return { + targetTeamID = targetTeamID, + unitIDs = unitIDs, + } +end + +return LuaRulesMsg 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_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_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/luaui/Tests/sharing/test_resource_transfer.lua b/luaui/Tests/sharing/test_resource_transfer.lua new file mode 100644 index 00000000000..a370f852444 --- /dev/null +++ b/luaui/Tests/sharing/test_resource_transfer.lua @@ -0,0 +1,85 @@ +---@diagnostic disable: lowercase-global, undefined-field + +local LuaRulesMsg = VFS.Include("common/luaUtilities/lua_rules_msg.lua") +local TransferEnums = VFS.Include("modules/sharing/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 + +local function skip() + return Spring.GetGameFrame() <= 0 +end + +local function setup() + Test.clearMap() + + assert(Game.nativeExcessSharing == false, "this test requires Lua-owned resource sharing (Game.nativeExcessSharing must be false)") + + local team0 = Spring.GetLocalTeamID() + 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 + +local function cleanup() + Test.clearMap() +end + +local function test() + local team0 = Spring.GetLocalTeamID() + 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 + +return { + skip = skip, + setup = setup, + cleanup = cleanup, + test = test, +} diff --git a/luaui/Tests/sharing/test_unit_transfer_disabled.lua b/luaui/Tests/sharing/test_unit_transfer_disabled.lua new file mode 100644 index 00000000000..daea85938c1 --- /dev/null +++ b/luaui/Tests/sharing/test_unit_transfer_disabled.lua @@ -0,0 +1,79 @@ +---@diagnostic disable: lowercase-global, undefined-field, duplicate-set-field + +local UnitTransferUnsynced = VFS.Include("modules/sharing/unit/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 + +local function skip() + return Spring.GetGameFrame() <= 0 +end + +local function setup() + Test.clearMap() + + SyncedRun(function() + ---@diagnostic disable-next-line: global-in-non-module + _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 + +local function cleanup() + SyncedRun(function() + local orig = _G._origGetModOptions --[[@as function?]] + if orig then + Spring.GetModOptions = orig + ---@diagnostic disable-next-line: global-in-non-module + _G._origGetModOptions = nil + end + end, 60) + + -- Let the cache refresh back to the real mod options + Test.waitFrames(160) + + Test.clearMap() +end + +local function test() + local team0 = Spring.GetLocalTeamID() + 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 + +return { + skip = skip, + setup = setup, + cleanup = cleanup, + test = test, +} diff --git a/luaui/Tests/sharing/test_unit_transfer_enabled.lua b/luaui/Tests/sharing/test_unit_transfer_enabled.lua new file mode 100644 index 00000000000..238f8ebc569 --- /dev/null +++ b/luaui/Tests/sharing/test_unit_transfer_enabled.lua @@ -0,0 +1,58 @@ +---@diagnostic disable: lowercase-global, undefined-field + +local UnitTransferUnsynced = VFS.Include("modules/sharing/unit/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 + +local function skip() + return Spring.GetGameFrame() <= 0 +end + +local function setup() + Test.clearMap() +end + +local function cleanup() + Test.clearMap() +end + +local function test() + local team0 = Spring.GetLocalTeamID() + 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 + +return { + skip = skip, + setup = setup, + cleanup = cleanup, + test = test, +} diff --git a/modoptions.lua b/modoptions.lua index 92a48223f7f..d9298e7eb99 100644 --- a/modoptions.lua +++ b/modoptions.lua @@ -295,43 +295,6 @@ local options = { section = "options_main", type = "separator", }, - { - key = "sub_header", - name = "-- Sharing and Taxes", - section = "options_main", - type = "subheader", - def = true, - }, - { - key = "tax_resource_sharing_amount", - name = "Resource Sharing Tax", - desc = "Taxes resource sharing".."\255\128\128\128".." and overflow (engine TODO:)\n".. - "Set to [0] to turn off. Recommended: [0.4]. (Ranges: 0 - 0.99)", - type = "number", - def = 0, - min = 0, - max = 0.99, - step = 0.01, - section = "options_main", - column = 1, - }, - { - key = "disable_unit_sharing", - name = "Disable Unit Sharing", - desc = "Disable sharing units and structures to allies", - type = "bool", - section = "options_main", - def = false, - }, - { - key = "disable_assist_ally_construction", - name = "Disable Assist Ally Construction", - desc = "Disables assisting allied blueprints and labs.", - type = "bool", - section = "options_main", - def = false, - column = 1.66, - }, { key = "sub_header", section = "options_main", @@ -1696,28 +1659,6 @@ local options = { linkwidth = 350, }, - { - key = "easytax", - name = "Easy Tax v2", - desc = "Anti co-op sharing tax mod. Overwrites other tax settings. Don't combine with other sharing restriction mods, everything you need is included with easy tax.", - type = "bool", - section = "options_experimental", - def = false, - }, - - { - key = "easytax_link", - name = "Changelog", - desc = "Easy Tax v2 description.", - section = "options_experimental", - type = "link", - link = "https://gist.github.com/RebelNode/43b986f29b9cfacbe95cf634cac25c49", - width = 215, - column = 1.65, - linkheight = 325, - linkwidth = 350, - }, - -- Hidden Tests { @@ -2479,4 +2420,9 @@ for i = 1, 9 do } end +-- module-owned fragments, appended in handler order +for _, option in ipairs(VFS.Include("modules/module_handler.lua").ModOptions()) do + options[#options + 1] = option +end + return options diff --git a/modules/transfer/actions/give.lua b/modules/transfer/actions/give.lua new file mode 100644 index 00000000000..3619beeaa79 --- /dev/null +++ b/modules/transfer/actions/give.lua @@ -0,0 +1,36 @@ +--- Hand units over with no policy question asked. +--- +--- The giver is the game itself, not a team choosing to share: a mission +--- handing a garrison to the player, a seat being taken over. A mode has no +--- say, which is why this is a separate action rather than a flag on units — +--- bypassing policy should be something you can see in the call. + +--- The give, with its movement injected: fiat transfer, no policy asked. +---@class TransferGiveRequest +---@field to integer receiving team +---@field unitIDs integer[] +---@field move fun(unitIDs: integer[], to: integer, fiat: boolean): integer moved count + +---@param request TransferGiveRequest +---@return boolean allowed, string? reason +Actions.RegisterValidate(function(request) + if type(request) ~= "table" then + return false, "transfer.give expects a request table" + end + if type(request.to) ~= "number" then + return false, "transfer.give needs a receiving team id" + end + if type(request.unitIDs) ~= "table" or #request.unitIDs == 0 then + return false, "transfer.give needs a non-empty unitIDs list" + end + if type(request.move) ~= "function" then + return false, "the unit transfer controller has not initialized" + end + return true +end) + +---@param request TransferGiveRequest +---@return integer transferred +Actions.RegisterExecute(function(request) + return request.move(request.unitIDs, request.to, true) +end) diff --git a/modules/transfer/actions/give_resources.lua b/modules/transfer/actions/give_resources.lua new file mode 100644 index 00000000000..0570c51a599 --- /dev/null +++ b/modules/transfer/actions/give_resources.lua @@ -0,0 +1,49 @@ +--- Move a team's resources with no policy question asked. +--- +--- The receiving side of a seat takeover: /take does not ask the sharing +--- mode whether it may, and must not pay its tax — the assets are not being +--- shared, they are changing hands with the seat. Take's own policy decides +--- whether the takeover happens at all; by the time this runs, it has. + +--- A fiat resource grant: no tax, no policy, the game moving the economy. +---@class TransferGiveResourcesRequest +---@field from integer giving team +---@field to integer receiving team +---@field resource "metal"|"energy" +---@field amount number +---@field add fun(teamID: integer, resource: string, amount: number) +---@field take fun(teamID: integer, resource: string, amount: number) + +---@param request TransferGiveResourcesRequest +---@return boolean allowed, string? reason +Actions.RegisterValidate(function(request) + if type(request) ~= "table" then + return false, "transfer.give_resources expects a request table" + end + if type(request.from) ~= "number" or type(request.to) ~= "number" then + return false, "transfer.give_resources needs from and to team ids" + end + if request.from == request.to then + return false, "a team cannot hand resources to itself" + end + if request.resource ~= "metal" and request.resource ~= "energy" then + return false, 'transfer.give_resources needs resource = "metal" or "energy"' + end + if type(request.amount) ~= "number" or request.amount <= 0 then + return false, "transfer.give_resources needs a positive amount" + end + if type(request.add) ~= "function" or type(request.take) ~= "function" then + return false, "the resource transfer controller has not initialized" + end + return true +end) + +---@param request TransferGiveResourcesRequest +---@return number moved +Actions.RegisterExecute(function(request) + -- Whole amount, untaxed, both sides adjusted: no pipeline, because there + -- is no policy to apply. + request.take(request.from, request.resource, -request.amount) + request.add(request.to, request.resource, request.amount) + return request.amount +end) diff --git a/modules/transfer/actions/resources.lua b/modules/transfer/actions/resources.lua new file mode 100644 index 00000000000..81be2b0be12 --- /dev/null +++ b/modules/transfer/actions/resources.lua @@ -0,0 +1,45 @@ +--- Send resources to another team. +--- +--- The module's one effectful path for metal and energy changing hands. The +--- tax a mode levies, the share level that triggers an automatic send and the +--- ledger that records it all hang off this one execute. +--- +--- validate is the precondition, pure. execute prices and sends. + +--- A resource share between allied teams, taxed and announced downstream. +---@class TransferResourcesRequest +---@field from integer giving team +---@field to integer receiving team +---@field resource "metal"|"energy" +---@field amount number +---@field send fun(from: integer, to: integer, resource: string, amount: number): number sent amount + +---@param request TransferResourcesRequest +---@return boolean allowed, string? reason +Actions.RegisterValidate(function(request) + if type(request) ~= "table" then + return false, "transfer.resources expects a request table" + end + if type(request.from) ~= "number" or type(request.to) ~= "number" then + return false, "transfer.resources needs from and to team ids" + end + if request.from == request.to then + return false, "a team cannot send resources to itself" + end + if request.resource ~= "metal" and request.resource ~= "energy" then + return false, 'transfer.resources needs resource = "metal" or "energy"' + end + if type(request.amount) ~= "number" or request.amount <= 0 then + return false, "transfer.resources needs a positive amount" + end + if type(request.send) ~= "function" then + return false, "the resource transfer controller has not initialized" + end + return true +end) + +---@param request TransferResourcesRequest +---@return ResourceTransferResult +Actions.RegisterExecute(function(request) + return request.send(request.from, request.to, request.resource, request.amount) +end) diff --git a/modules/transfer/actions/units.lua b/modules/transfer/actions/units.lua new file mode 100644 index 00000000000..df5d54fc1ea --- /dev/null +++ b/modules/transfer/actions/units.lua @@ -0,0 +1,51 @@ +--- Hand units to another team. +--- +--- The module's one effectful path for units changing hands: everything that +--- moves a unit between teams — a player dragging a selection onto an ally, a +--- mission handing over a garrison, /take emptying a seat — arrives here, so +--- the mode's policy is asked once and the announcement happens once. +--- +--- validate is the precondition, pure: it answers whether this pair of teams +--- may share at all under the active mode. execute is the only code that +--- moves anything; it re-checks per unit, because a unit can stop qualifying +--- between the question and the answer. + +local Shared = VFS.Include("modules/transfer/unit/shared.lua") + +--- A unit share between allied teams, policy asked per giving team. +---@class TransferUnitsRequest +---@field from integer giving team +---@field to integer receiving team +---@field unitIDs integer[] +---@field share fun(from: integer, to: integer, unitIDs: integer[]): integer shared count + +---@param request TransferUnitsRequest +---@return boolean allowed, string? reason +Actions.RegisterValidate(function(request) + if type(request) ~= "table" then + return false, "transfer.units expects a request table" + end + if type(request.from) ~= "number" or type(request.to) ~= "number" then + return false, "transfer.units needs from and to team ids" + end + if request.from == request.to then + return false, "a team cannot share with itself" + end + if type(request.unitIDs) ~= "table" or #request.unitIDs == 0 then + return false, "transfer.units needs a non-empty unitIDs list" + end + if type(request.share) ~= "function" then + return false, "the unit transfer controller has not initialized" + end + local policy = Shared.GetCachedPolicyResult(request.from, request.to, Spring) + if not (policy and policy.canShare) then + return false, "the active mode does not allow unit transfer between these teams" + end + return true +end) + +---@param request TransferUnitsRequest +---@return UnitTransferResult +Actions.RegisterExecute(function(request) + return request.share(request.from, request.to, request.unitIDs) +end) diff --git a/modules/transfer/api.lua b/modules/transfer/api.lua new file mode 100644 index 00000000000..7c75b6605a5 --- /dev/null +++ b/modules/transfer/api.lua @@ -0,0 +1,127 @@ +--- Synced contract of the transfer module: the one way things change hands. +--- +--- Every verb here runs a declared action from actions/ — validate, then +--- execute — so the module has exactly one effectful path per capability and +--- the framework can see it. This file holds no state and makes no decisions; +--- it is the calling convention, not the behaviour. + +local ModuleHandler = VFS.Include("modules/module_handler.lua") +local UnitShared = VFS.Include("modules/transfer/unit/shared.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") + +-- Refilled per call: the engine asks this once per unit per transfer attempt. +local mayUnitScratch = {} +local mayValidationScratch = {} + +---Run a declared action: refuse on its own precondition, never halfway. +---@param name string action file name under actions/ +---@param request table +---@return any result +local function perform(name, request) + local action = ModuleHandler.LoadActions("transfer").byName[name] + if action == nil then + -- Refuse, do not raise: this runs inside engine callins, and a missing + -- action must not take the callin down with it. The usual cause is a + -- file added while the game was running — the registry is memoised and + -- the VFS listing is not re-scanned, so a restart is what picks it up. + Spring.Log("transfer", LOG.ERROR, + "transfer has no action named " .. tostring(name) .. " (added since the game started? restart to pick it up)") + return nil + end + if action.validate then + local allowed, reason = action.validate(request) + if not allowed then + Spring.Log("transfer", LOG.WARNING, "transfer." .. name .. " refused: " .. tostring(reason)) + return nil + end + end + return action.execute(request) +end + +return { + ---Hand units to another team through the sharing pipeline: the active + ---mode's policy decides whether it happens at all, and what it costs. + ---A caller that wants the transfer regardless of policy wants Give. + ---@param unitIDs integer[] + ---@param toTeamID integer + ---@param fromTeamID integer the team being asked to give them up + ---@return UnitTransferResult + Units = function(unitIDs, toTeamID, fromTeamID) + -- The pipeline entry point travels in the request: an action file runs + -- in the registrar's environment, where GG is not the gadget's GG. + return perform("units", { + from = fromTeamID, + to = toTeamID, + unitIDs = unitIDs, + share = GG and GG.ShareUnits, + }) + end, + + ---Send metal or energy, priced by the active mode. + ---@param resource ResourceName + ---@param amount number + ---@param toTeamID integer + ---@param fromTeamID integer + ---@return ResourceTransferResult + Resources = function(resource, amount, toTeamID, fromTeamID) + return perform("resources", { + from = fromTeamID, + to = toTeamID, + resource = resource, + amount = amount, + send = GG and GG.ShareTeamResource, + }) + end, + + ---May this one unit move between these teams? The engine asks through + ---AllowUnitTransfer; the gadget there is an adapter, and this is the + ---answer — so an engine hook and a deliberate share cannot disagree. + ---@param unitID integer + ---@param fromTeamID integer + ---@param toTeamID integer + ---@param capture boolean|nil engine-driven capture, never a policy question + ---@return boolean + MayTransfer = function(unitID, fromTeamID, toTeamID, capture) + if capture then + return true + end + -- /take moves a whole seat under its own policy; per-unit rules do not + -- apply to the sweep it performs. + if Spring.GetGameRulesParam("isTakeInProgress") == 1 then + return true + end + local policyResult = UnitShared.GetCachedPolicyResult(fromTeamID, toTeamID, Spring) + mayUnitScratch[1] = unitID + local validation = UnitShared.ValidateUnits(policyResult, mayUnitScratch, Spring, nil, mayValidationScratch) + return validation.status ~= TransferEnums.UnitValidationOutcome.Failure + end, + + ---Hand a team's resources over with no policy question asked: a seat + ---changing hands, not a team choosing to share. Untaxed by definition. + ---@param resource ResourceName + ---@param amount number + ---@param toTeamID integer + ---@param fromTeamID integer + ---@return number moved + GiveResources = function(resource, amount, toTeamID, fromTeamID) + return perform("give_resources", { + from = fromTeamID, + to = toTeamID, + resource = resource, + amount = amount, + add = GG and GG.AddTeamResource, + take = GG and GG.AddTeamResource, + }) + end, + + ---Move units with no policy question asked: the giver is the game itself, + ---not a team choosing to share. Scripted handovers use this when the mode + ---is not meant to have a say — and a mode that IS meant to have a say + ---should see a Units call instead. + ---@param unitIDs integer[] + ---@param toTeamID integer + ---@return integer transferred + Give = function(unitIDs, toTeamID) + return perform("give", { to = toTeamID, unitIDs = unitIDs, move = GG and GG.TransferUnits }) + end, +} diff --git a/modules/transfer/economy/manual_share_ledger.lua b/modules/transfer/economy/manual_share_ledger.lua new file mode 100644 index 00000000000..09ccfb731e7 --- /dev/null +++ b/modules/transfer/economy/manual_share_ledger.lua @@ -0,0 +1,60 @@ +local M = {} + +local ledger = {} ---@type table> + +local function entry(teamId, resourceType) + local perTeam = ledger[teamId] + if not perTeam then + perTeam = {} + ledger[teamId] = perTeam + end + local e = perTeam[resourceType] + if not e then + e = { sent = 0, received = 0 } + perTeam[resourceType] = e + end + return e +end + +---@param senderTeamId number +---@param receiverTeamId number +---@param resourceType ResourceName +---@param sent number +---@param received number +function M.Record(senderTeamId, receiverTeamId, resourceType, sent, received) + local s = entry(senderTeamId, resourceType) + s.sent = s.sent + sent + local r = entry(receiverTeamId, resourceType) + r.received = r.received + received +end + +--- Fold accumulated manual-share stats into the redistribution result entries, then clear. +---@param results EconomyTeamResult[] +function M.FoldInto(results) + if not next(ledger) then + return results + end + for i = 1, #results do + local result = results[i] + local perTeam = ledger[result.teamId] ---@type table? sparse per-team ledger + local e = perTeam and perTeam[result.resourceType] + if e then + result.sent = result.sent + e.sent + result.received = result.received + e.received + e.sent = 0 + e.received = 0 + end + end + return results +end + +function M.Clear() + for teamId, perTeam in pairs(ledger) do + for resourceType, e in pairs(perTeam) do + e.sent = 0 + e.received = 0 + end + end +end + +return M diff --git a/modules/transfer/economy/share_stats.lua b/modules/transfer/economy/share_stats.lua new file mode 100644 index 00000000000..9c51fee507e --- /dev/null +++ b/modules/transfer/economy/share_stats.lua @@ -0,0 +1,65 @@ +-- 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 Spring +---@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/modules/transfer/economy/shared_config.lua b/modules/transfer/economy/shared_config.lua new file mode 100644 index 00000000000..dddf69ad2e4 --- /dev/null +++ b/modules/transfer/economy/shared_config.lua @@ -0,0 +1,57 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local MOD_OPTIONS = ModeEnums.ModOptions +local TechBlockingShared = VFS.Include("modules/transfer/resource/tax.lua") + +local M = {} + +---@type number? +local cachedTax = nil +---@type boolean? +local cachedSharingEnabled = nil + +function M.resetCache() + cachedTax = nil + cachedSharingEnabled = nil +end + +---@param springRepo Spring +---@return boolean +function M.isResourceSharingEnabled(springRepo) + if cachedSharingEnabled ~= nil then + return cachedSharingEnabled + end + local v = springRepo.GetModOptions()[MOD_OPTIONS.ResourceSharingEnabled] + cachedSharingEnabled = not (v == false or v == "0") + return cachedSharingEnabled +end + +---@param springRepo Spring +---@return number tax Base tax rate +function M.getTaxConfig(springRepo) + if cachedTax then + return cachedTax + end + + local modOpts = springRepo.GetModOptions() + local tax = tonumber(modOpts[MOD_OPTIONS.TaxResourceSharingAmount]) or 0 + if tax < 0 then + tax = 0 + end + if tax > 1 then + tax = 1 + end + + cachedTax = tax + + return tax +end + +-- per-team tax rate by tech level; degrades to base rate when tech_blocking inactive +---@param springRepo Spring +---@param teamId number +---@return number +function M.getTeamTaxRate(springRepo, teamId) + return TechBlockingShared.GetTaxRate(teamId, nil, springRepo) +end + +return M diff --git a/modules/transfer/gadgets/cmd_take.lua b/modules/transfer/gadgets/cmd_take.lua new file mode 100644 index 00000000000..fc38d60a4b7 --- /dev/null +++ b/modules/transfer/gadgets/cmd_take.lua @@ -0,0 +1,228 @@ +function gadget:GetInfo() + return { + name = "Take Command", + desc = "Implements /take command to transfer units/resources from empty allied teams", + author = "Antigravity", + date = "2024", + license = "GPL-v2", + layer = 0, + enabled = true, + } +end + +if not gadgetHandler:IsSyncedCode() then + return +end + +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local Shared = VFS.Include("modules/transfer/unit/shared.lua") +local TakeComms = VFS.Include("modules/transfer/take/comms.lua") +local TransferApi = VFS.Include("modules/transfer/api.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 + +--- A takeover is not a share: the assets move with the seat, so they do not +--- go through the sharing pipeline and are not taxed by the sharing mode. +--- Whether the takeover may happen at all is take's own policy, decided +--- before this runs. +local function transferResources(fromTeamID, toTeamID) + for _, resource in ipairs({ "metal", "energy" }) do + local amount = GG.GetTeamResources and GG.GetTeamResources(fromTeamID, resource) + if amount and amount > 0 then + TransferApi.GiveResources(resource, amount, toTeamID, fromTeamID) + end + 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/modules/transfer/gadgets/game_resource_transfer_controller.lua b/modules/transfer/gadgets/game_resource_transfer_controller.lua new file mode 100644 index 00000000000..1621ccaac42 --- /dev/null +++ b/modules/transfer/gadgets/game_resource_transfer_controller.lua @@ -0,0 +1,371 @@ +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("modules/context/team_resource_data.lua") +local ShareStats = VFS.Include("modules/transfer/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) or 0 + return Spring.SetTeamResource(teamID, resource, current + amount) +end + +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") +local ContextFactoryModule = VFS.Include("modules/context/context_factory.lua") +local ResourceTransfer = VFS.Include("modules/transfer/resource/synced.lua") +local Shared = VFS.Include("modules/transfer/resource/shared.lua") +local TransferApi = VFS.Include("modules/transfer/api.lua") +local Comms = VFS.Include("modules/transfer/resource/comms.lua") +local TechBlockingShared = VFS.Include("modules/transfer/resource/tax.lua") +local LuaRulesMsg = VFS.Include("common/luaUtilities/lua_rules_msg.lua") +local ManualShareLedger = VFS.Include("modules/transfer/economy/manual_share_ledger.lua") + +local WaterfillSolver = VFS.Include("modules/economy/lib/waterfill_solver.lua") + +-- cast: the library meta declares tracy unconditionally, but profiler-less engine builds lack it +local tracyAvailable = (tracy and tracy.ZoneBeginN and tracy.ZoneEnd) ~= nil --[[@as boolean]] + +local modOptions = Spring.GetModOptions() + +local METAL = ResourceTypes.METAL +local ENERGY = ResourceTypes.ENERGY + +local springRepo = Spring + +local spGetUnitIsBeingBuilt = springRepo.GetUnitIsBeingBuilt +local spGetUnitTeam = springRepo.GetUnitTeam +local spGetTeamResources = springRepo.GetTeamResources +local spUseUnitResource = Spring.UseUnitResource +local spGetFeatureResources = Spring.GetFeatureResources +local spGetFeatureResurrect = Spring.GetFeatureResurrect +local spAreTeamsAllied = springRepo.AreTeamsAllied +local spSetTeamResource = springRepo.SetTeamResource +local spAddTeamResourceExcessStats = springRepo.AddTeamResourceExcessStats +local spGetTeamInfo = springRepo.GetTeamInfo +local spGetTeamList = springRepo.GetTeamList +local spGetGameFrame = springRepo.GetGameFrame + +local gaiaTeamID = springRepo.GetGaiaTeamID() + +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 integer Sender team ID +---@param targetTeamID integer Receiver team ID +---@param resource 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) + + local policyResult = transferResult.policyResult + if transferResult.success and policyResult then + ManualShareLedger.Record(teamID, targetTeamID, policyResult.resourceType, transferResult.sent, transferResult.received) + Comms.SendTransferChatMessages(transferResult, policyResult) + end + + return transferResult +end + +---@param teamID integer +---@param resource 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 integer +---@param resource 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 = springRepo.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 = {} ---@type table + 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 = { + allyTeam = 0, + isDead = false, + metal = { resourceType = METAL, current = 0, storage = 0, shareSlider = 0, sent = 0, received = 0, excess = 0 }, + energy = { resourceType = ENERGY, current = 0, storage = 0, shareSlider = 0, sent = 0, received = 0, excess = 0 }, + } + snapshotPool[teamID] = entry + end + entry.allyTeam = allyTeam + entry.isDead = isDead + + local m = entry.metal --[[@as ResourceData]] + m.current = mCur + m.storage = mStor + m.shareSlider = mShare + m.excess = acc and acc[1] or 0 + + local e = entry.energy --[[@as ResourceData]] + 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) + if spAddTeamResourceExcessStats then + spAddTeamResourceExcessStats(r.teamId, r.resourceType, r.excess) + end + 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 + +if not springRepo.AddTeamResourceExcessStats then + -- Engine without the resource-excess port (RecoilEngine#2642): the + -- ResourceExcess callin never fires, so drive the cadence tick — and the + -- policy factor refresh manual sharing depends on — from GameFrame. + -- Overflow accumulation is unavailable; only slider excess redistributes. + function gadget:GameFrame(frame) + if frame % CADENCE == 0 then + ProcessEconomy(frame) + end + end +end + +function gadget:RecvLuaMsg(msg, playerID) + local params = LuaRulesMsg.ParseResourceShare(msg) + if params then + -- Same action a mission or a script would run; the controller is the + -- transport for the request, not a second way in. + TransferApi.Resources(params.resourceType, params.amount, params.targetTeamID, params.senderTeamID) + 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 --[[@as integer]], 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/modules/transfer/gadgets/game_share_policy_forwarding.lua b/modules/transfer/gadgets/game_share_policy_forwarding.lua new file mode 100644 index 00000000000..5c78f486f77 --- /dev/null +++ b/modules/transfer/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 (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/modules/transfer/gadgets/game_unit_transfer_controller.lua b/modules/transfer/gadgets/game_unit_transfer_controller.lua new file mode 100644 index 00000000000..99b5f29435a --- /dev/null +++ b/modules/transfer/gadgets/game_unit_transfer_controller.lua @@ -0,0 +1,238 @@ +---@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("modules/context/enums.lua") +local ContextFactoryModule = VFS.Include("modules/context/context_factory.lua") +local Shared = VFS.Include("modules/transfer/unit/shared.lua") +local TransferApi = VFS.Include("modules/transfer/api.lua") +local Construction = VFS.Include("modules/construction/api.lua") +local UnitTransfer = VFS.Include("modules/transfer/unit/synced.lua") +local UnitSharingCategories = VFS.Include("modules/transfer/unit/categories.lua") +local LuaRulesMsg = VFS.Include("common/luaUtilities/lua_rules_msg.lua") + + +-- mobile builders get buildspeed-0 debuff instead of stun + + +-- reused scratch tables; separate ones because AllowUnitTransfer fires inside ShareUnits' loop (shared would clobber) +local shareValidationScratch = {} + +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) + -- Construction serves the delay: it owns the answer the engine asks for + -- (may this builder build), and it sits underneath this module. + Construction.DelayBuilder(unitID, stunSeconds) +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 + +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 integer +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 integer +---@param newTeamID integer +---@param given boolean? +function GG.TransferUnit(unitID, newTeamID, given) + springRepo.TransferUnit(unitID, newTeamID, given or false) +end + +---@param unitIDs integer[] +---@param newTeamID integer +---@param given boolean? +---@return integer 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 integer +---@param targetTeamID integer +---@param unitIDs integer[] +---@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 validation.status == TransferEnums.UnitValidationOutcome.Failure then + ---@type UnitTransferResult + return { + success = false, + outcome = validation.status, + senderTeamId = senderTeamID, + receiverTeamId = targetTeamID, + validationResult = validation, + 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 integer +---@param unitDefID integer +---@param fromTeamID integer +---@param toTeamID integer +---@param capture boolean +---@return boolean +function UnitTransferController.AllowUnitTransfer(unitID, unitDefID, fromTeamID, toTeamID, capture) + -- The engine hook is an adapter: the module's API owns the answer, so a + -- unit the engine offers to move and a unit a player offers to share are + -- judged by the same code. + return TransferApi.MayTransfer(unitID, fromTeamID, toTeamID, capture) +end + +---@param srcTeamID integer +---@param dstTeamID integer +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 --[[@as integer]], dstTeamID --[[@as integer]]) +end + +function gadget:RecvLuaMsg(msg, playerID) + local params = LuaRulesMsg.ParseUnitTransfer(msg) + if params then + local _, _, _, senderTeamID = springRepo.GetPlayerInfo(playerID, false) + if senderTeamID then + -- Through the module's own action, so a player dragging units onto + -- an ally takes the same path as a mission handing them over. + TransferApi.Units(params.unitIDs, params.targetTeamID, senderTeamID) + end + return true + end + return false +end + +function gadget:GameFrame(frame) + UpdatePolicyCache(frame) + +end + + diff --git a/modules/transfer/lib/actions.lua b/modules/transfer/lib/actions.lua new file mode 100644 index 00000000000..1c61c8b47a9 --- /dev/null +++ b/modules/transfer/lib/actions.lua @@ -0,0 +1,90 @@ +--- Transfer's actions, declared once. +--- +--- An action is a thing this module can do. A mode NAMES one to grant it +--- (`.Allow(Transfer.Units)`); a mission CALLS one to perform it +--- (`.Do(Transfer.Units(group, team))`). Both grammars are talking about the +--- same entry in this table, which is the point: they cannot drift, because +--- there is nothing to keep in step. +--- +--- Two facets, either of which may be absent: +--- domain/category/tier the mode facet — what a grant is written against. +--- Absent means no mode can speak about it (Give). +--- Perform the mission facet — what performing it does, given +--- the mission ctx. Absent means no trigger can run it +--- (Assist, Reclaim, Build, Tech: mode-only, so far). + +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") +local ConstructionActions = VFS.Include("modules/construction/lib/actions.lua") + +--- Which resource a grant is narrowed to. Same shape as a unit category, so +--- .Tax(Transfer.Resources.Metal, 0.3) reads like .Stun(Transfer.Units.Resource, 30). +local ResourceCategory = { Metal = ResourceTypes.METAL, Energy = ResourceTypes.ENERGY } + +--- A noun a grant is written against. Sub-nouns (.Units, .Resources.Metal, +--- .AtT2, ...) are the same shape narrowed, which the one indexer says, so a +--- preset's whole noun path stays typed. The facets themselves (domain, +--- category, tier, Perform) stay untyped here — only the DSL boundary reads +--- them, and it does so from untyped runtime code. +---@class TransferGrant +---@field [string] TransferGrant + +local Actions = {} + +--- .AtT2/.AtT3 variants carry the tech tier; they are the same action, said of +--- a later tier, so they inherit the domain and carry no facet of their own. +---@param action table +---@return table +local function withTiers(action) + action.AtT2 = { domain = action.domain, category = action.category, tier = 2 } + action.AtT3 = { domain = action.domain, category = action.category, tier = 3 } + return action +end + +---@param action table +---@param categories table field name -> category value +---@param tiers boolean|nil +---@return table +local function withCategories(action, categories, tiers) + for enumName, category in pairs(categories) do + action[enumName] = { domain = action.domain, category = category } + if tiers then + withTiers(action[enumName]) + end + end + return action +end + +Actions.Transfer = { + Units = withCategories(withTiers({ + domain = "unit", + ---@param ctx MissionContext + ---@param group string + ---@param teamID integer + Perform = function(ctx, group, teamID) + ctx.TransferGroup(group, teamID, false) + end, + }), ModeEnums.UnitCategory, true), + -- Metal and Energy: the rate behind them is one modoption today, so both + -- resolve the same. The narrowing is real all the same — the policy takes + -- the resource, so pricing them apart is a change of input, not of shape. + Resources = withCategories(withTiers({ domain = "resource" }), ResourceCategory, true), + -- No domain: fiat is not a thing a mode can grant or refuse. A mission + -- that must not be refused says Give, and says it in the open. + Give = { + ---@param ctx MissionContext + ---@param group string + ---@param teamID integer + Perform = function(ctx, group, teamID) + ctx.TransferGroup(group, teamID, true) + end, + }, +} +-- Construction declares what a builder may do for an ally; the sharing mode +-- grants those alongside transfer's own, so a preset reads as one list. +Actions.Construction = ConstructionActions.Construction + +Actions.Take = withCategories({ domain = "take" }, ModeEnums.UnitCategory) +Actions.Tech = { domain = "tech" } + +return Actions diff --git a/modules/transfer/mode_dsl.lua b/modules/transfer/mode_dsl.lua new file mode 100644 index 00000000000..4ba572293c4 --- /dev/null +++ b/modules/transfer/mode_dsl.lua @@ -0,0 +1,99 @@ +--- Transfer mode vocabulary over modules/mode_builder.lua: nouns for the +--- sharing domains, verbs mapping them onto the pipeline's policy identities. +--- Chain mechanics, lock model, and the bundle contract live in the builder; +--- preset files read as: +--- +--- Mode("Disabled") +--- .Desc("Disable all sharing.") +--- .Deny(Transfer.Resources) +--- .Tax(Transfer.Resources, 0.30).Hidden().Unlocked() + +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local Actions = VFS.Include("modules/transfer/lib/actions.lua") +local Bundle = VFS.Include("modules/transfer/policy_bundle.lua") +local ModeBuilder = VFS.Include("modules/mode_builder.lua") + +--- What a preset file gets from including this module. The chain type lives +--- in types/mode_policy.lua — the same surface the kit reads — so the +--- language server resolves verbs the Grammar only builds at runtime. +--- Consumers annotate their include: +--- +--- local ModeDSL = VFS.Include("modules/transfer/mode_dsl.lua") ---@type TransferModeDSL +---@class TransferModeDSL +---@field Mode fun(name: string): TransferModeChain Start a preset. The category is not a parameter: the grammar binds every chain from this module to "transfer" — the name only names it. +---@field Transfer TransferGrant +---@field Construction TransferGrant +---@field Take TransferGrant +---@field Tech TransferGrant + +local M = {} +---@cast M TransferModeDSL + +-- The nouns ARE the module's actions: a grant is written against the same +-- table entry a mission calls. Declared in lib/actions.lua, once. +for name, action in pairs(Actions) do + M[name] = action +end + +local HINT = "Transfer.*, Construction.*, Take, Tech" +local ALLOW_DENY = { unit = true, resource = true, assist = true, reclaim = true, resurrect = true, take = true } +local STUN = { unit = true, take = true } +local DELAY = { build = true, take = true } + +M.Mode = ModeBuilder.Grammar({ + category = ModeEnums.ModeCategories.Transfer, + serializers = Bundle.Serializers, + verbs = { + ---Allow the noun's domain (a category sub-noun narrows unit sharing). + Allow = function(name, noun) + local domain = ModeBuilder.DomainOf(name, "Allow", noun, ALLOW_DENY, HINT) + return { domain .. ".allow", category = noun.category, tier = noun.tier } + end, + ---Deny the noun's domain outright. + Deny = function(name, noun) + return { ModeBuilder.DomainOf(name, "Deny", noun, ALLOW_DENY, HINT) .. ".deny", tier = noun.tier } + end, + ---Tax resource sharing at a rate in [0,1]. + Tax = function(name, noun, rate) + ModeBuilder.DomainOf(name, "Tax", noun, { resource = true }, "Transfer.Resources[.AtT2/.AtT3]") + return { "resource.tax", rate = rate, tier = noun.tier, category = noun.category } + end, + ---Stun shared units of the noun's category for seconds (Transfer.Units.*), + ---or stun taken units for the take delay (Take). + Stun = function(name, noun, seconds) + local domain = ModeBuilder.DomainOf(name, "Stun", noun, STUN, "Transfer.Units. or Take") + if domain == "take" then + return { "take.stun" } + end + assert(noun.category, name .. ": .Stun(Transfer.Units., seconds)") + return { "unit.stun", seconds = seconds, category = noun.category } + end, + ---Take arrives only after the delay. + Defer = function(name, noun) + ModeBuilder.DomainOf(name, "Defer", noun, { take = true }, "Take") + return { "take.defer" } + end, + ---Delay dial: constructor build delay (Build.Constructors) or the take + ---delay and its category (Take.). + Delay = function(name, noun, seconds) + local domain = ModeBuilder.DomainOf(name, "Delay", noun, DELAY, "Build.Constructors or Take.") + if domain == "build" then + return { "build.delay", seconds = seconds } + end + assert(noun.category, name .. ": .Delay(Take., seconds)") + return { "take.delay", seconds = seconds, category = noun.category } + end, + ---Tech levels gate construction; thresholds are the pacing dials. + Gate = function(name, noun, t2, t3) + ModeBuilder.DomainOf(name, "Gate", noun, { tech = true }, "Tech") + return { "tech.gate", t2 = t2, t3 = t3 } + end, + ---Tech open (no gating); thresholds stay exposed as dials. + Open = function(name, noun, t2, t3) + ModeBuilder.DomainOf(name, "Open", noun, { tech = true }, "Tech") + return { "tech.open", t2 = t2, t3 = t3 } + end, + }, +}) --[[@as fun(name: string): TransferModeChain]] + +return M diff --git a/modules/transfer/mode_helpers.lua b/modules/transfer/mode_helpers.lua new file mode 100644 index 00000000000..fbbd3092915 --- /dev/null +++ b/modules/transfer/mode_helpers.lua @@ -0,0 +1,69 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") + +local M = {} + +--- Resolve a tech-level-varying modOption: _at_t3, then _at_t2, then baseKey. +function M.resolveByTechLevel(opts, baseKey, techLevel) + if techLevel >= 3 then + local v = opts[baseKey .. "_at_t3"] + if v ~= nil and v ~= "" then + return v + end + end + if techLevel >= 2 then + local v = opts[baseKey .. "_at_t2"] + if v ~= nil and v ~= "" then + return v + end + end + return opts[baseKey] +end + +M.unitSharingCategories = { + { key = ModeEnums.UnitCategory.Combat, name = "Combat", desc = "Combat units, commanders, and transports" }, + { key = ModeEnums.UnitCategory.Buildings, name = "Buildings", desc = "Factories, resource, and utility buildings" }, + { key = ModeEnums.UnitCategory.Constructors, name = "Constructors", desc = "Constructors and con turrets" }, + { key = ModeEnums.UnitCategory.Resource, name = "Resource", desc = "Metal extractors and energy producers" }, + { key = ModeEnums.UnitCategory.NonCombat, name = "Non-combat", desc = "Everything except combat units" }, +} + +M.unitSharingCategoriesWithAll = {} +for _, item in ipairs(M.unitSharingCategories) do + M.unitSharingCategoriesWithAll[#M.unitSharingCategoriesWithAll + 1] = item +end +M.unitSharingCategoriesWithAll[#M.unitSharingCategoriesWithAll + 1] = { + key = ModeEnums.UnitFilterCategory.All, + name = "All", + desc = "All units", +} + +M.unitSharingCategoriesWithNone = { + { key = "", name = "None", desc = "Use the base unit sharing mode" }, +} +for _, item in ipairs(M.unitSharingCategories) do + M.unitSharingCategoriesWithNone[#M.unitSharingCategoriesWithNone + 1] = item +end + +-- tech-level overrides (_at_t2 / _at_t3): None, All, plus individual categories. +M.unitSharingCategoriesWithNoneAndAll = { + { key = ModeEnums.UnitFilterCategory.None, name = "None", desc = "No additional unit sharing at this tech level" }, +} +for _, item in ipairs(M.unitSharingCategories) do + M.unitSharingCategoriesWithNoneAndAll[#M.unitSharingCategoriesWithNoneAndAll + 1] = item +end +M.unitSharingCategoriesWithNoneAndAll[#M.unitSharingCategoriesWithNoneAndAll + 1] = { + key = ModeEnums.UnitFilterCategory.All, + name = "All", + desc = "All units", +} + +-- None/All wording (not Disabled/Enabled) to match the tech-level selectors. +M.unitSharingModeItems = { + { key = ModeEnums.UnitFilterCategory.None, name = "None", desc = "No unit sharing allowed" }, + { key = ModeEnums.UnitFilterCategory.All, name = "All", desc = "All unit sharing allowed" }, +} +for _, item in ipairs(M.unitSharingCategories) do + M.unitSharingModeItems[#M.unitSharingModeItems + 1] = item +end + +return M diff --git a/modules/transfer/modes/customize.lua b/modules/transfer/modes/customize.lua new file mode 100644 index 00000000000..b710da02e47 --- /dev/null +++ b/modules/transfer/modes/customize.lua @@ -0,0 +1,24 @@ +local ModeDSL = VFS.Include("modules/transfer/mode_dsl.lua") ---@type TransferModeDSL +local Mode, Transfer, Construction, Take, Tech = + ModeDSL.Mode, ModeDSL.Transfer, ModeDSL.Construction, ModeDSL.Take, ModeDSL.Tech + +-- Every policy exposed unlocked; values are only the starting point (RetainValues). +return Mode("Customize") + .Desc("Tweak everything. Switching to Customize keeps the current mode's settings as a starting point (defaults if you start here).") + .Ranked() + .RetainValues() + .Open(Tech, 1, 1.5).Unlocked() + .Allow(Transfer.Units).Unlocked() + .Deny(Transfer.Units.AtT2).Unlocked() + .Deny(Transfer.Units.AtT3).Unlocked() + .Stun(Transfer.Units.Resource, 0).Unlocked() + .Delay(Construction.Build, 0).Unlocked() + .Allow(Transfer.Resources).Unlocked() + .Tax(Transfer.Resources, 0).Unlocked() + .Tax(Transfer.Resources.AtT2, -1).Unlocked() + .Tax(Transfer.Resources.AtT3, -1).Unlocked() + .Allow(Construction.Assist).Unlocked() + .Allow(Construction.Reclaim).Unlocked() + .Allow(Construction.Resurrect).Unlocked() + .Allow(Take).Unlocked() + .Delay(Take.Resource, 30).Unlocked() diff --git a/modules/transfer/modes/disabled.lua b/modules/transfer/modes/disabled.lua new file mode 100644 index 00000000000..4ad1ea27c4b --- /dev/null +++ b/modules/transfer/modes/disabled.lua @@ -0,0 +1,12 @@ +local ModeDSL = VFS.Include("modules/transfer/mode_dsl.lua") ---@type TransferModeDSL +local Mode, Transfer, Construction, Take = ModeDSL.Mode, ModeDSL.Transfer, ModeDSL.Construction, ModeDSL.Take + +return Mode("Disabled") + .Desc("No sharing of any kind: no resources, no units, no assisting or reclaiming an ally, no /take. Most sharing options are locked.") + .Ranked() + .Deny(Transfer.Units) + .Deny(Transfer.Resources) + .Tax(Transfer.Resources, 0.30).Hidden().Unlocked() + .Deny(Construction.Assist) + .Deny(Construction.Reclaim) + .Deny(Take).Unlocked() diff --git a/modules/transfer/modes/easy_tax.lua b/modules/transfer/modes/easy_tax.lua new file mode 100644 index 00000000000..28b82ed4eb5 --- /dev/null +++ b/modules/transfer/modes/easy_tax.lua @@ -0,0 +1,17 @@ +local ModeDSL = VFS.Include("modules/transfer/mode_dsl.lua") ---@type TransferModeDSL +local Mode, Transfer, Construction, Take = + ModeDSL.Mode, ModeDSL.Transfer, ModeDSL.Construction, ModeDSL.Take + +return Mode("Easy Tax") + .Desc("Anti co-op sharing tax. 30% tax on shared resources; shared eco buildings are stunned and shared constructors cannot build, both for 30 seconds.") + .Ranked() + .Allow(Transfer.Units) + .Stun(Transfer.Units.Resource, 30) + .Delay(Construction.Build, 30) + .Allow(Transfer.Resources) + .Tax(Transfer.Resources, 0.30) + .Allow(Construction.Assist) + .Allow(Construction.Reclaim) + .Allow(Construction.Resurrect) + .Stun(Take) + .Delay(Take.Resource, 30) diff --git a/modules/transfer/modes/enabled.lua b/modules/transfer/modes/enabled.lua new file mode 100644 index 00000000000..1d0388b7c99 --- /dev/null +++ b/modules/transfer/modes/enabled.lua @@ -0,0 +1,12 @@ +local ModeDSL = VFS.Include("modules/transfer/mode_dsl.lua") ---@type TransferModeDSL +local Mode, Transfer, Construction, Take = ModeDSL.Mode, ModeDSL.Transfer, ModeDSL.Construction, ModeDSL.Take + +return Mode("Enabled") + .Desc("All sharing on with fixed defaults.") + .Ranked() + .Allow(Transfer.Units) + .Allow(Transfer.Resources) + .Tax(Transfer.Resources, 0.0).Hidden().Locked() + .Allow(Construction.Assist) + .Allow(Construction.Reclaim) + .Allow(Take) diff --git a/modules/transfer/modes/tech_core.lua b/modules/transfer/modes/tech_core.lua new file mode 100644 index 00000000000..607ef22a1e6 --- /dev/null +++ b/modules/transfer/modes/tech_core.lua @@ -0,0 +1,20 @@ +local ModeDSL = VFS.Include("modules/transfer/mode_dsl.lua") ---@type TransferModeDSL +local Mode, Transfer, Construction, Take, Tech = + ModeDSL.Mode, ModeDSL.Transfer, ModeDSL.Construction, ModeDSL.Take, ModeDSL.Tech + +return Mode("Tech Core") + .Desc("Tech levels gate unit construction. Build Keystone buildings to advance. Sharing unlocks with tech. Legion's mex economy is rebalanced for the universal Voussoir.") + .Ranked() + .Gate(Tech, 1, 1.5) + .Deny(Transfer.Units) + .Allow(Transfer.Units.Constructors.AtT2) + .Deny(Transfer.Units.AtT3) + .Allow(Transfer.Resources) + .Tax(Transfer.Resources, 0.6) + .Tax(Transfer.Resources.AtT2, 0.5) + .Tax(Transfer.Resources.AtT3, 0.4) + .Allow(Construction.Assist) + .Allow(Construction.Reclaim) + .Deny(Construction.Resurrect) + .Defer(Take) + .Delay(Take.Resource, 60) diff --git a/modules/transfer/modoptions.lua b/modules/transfer/modoptions.lua new file mode 100644 index 00000000000..e4cc53fa10f --- /dev/null +++ b/modules/transfer/modoptions.lua @@ -0,0 +1,287 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local ModOptionHelpers = VFS.Include("modules/transfer/mode_helpers.lua") + +local unitSharingModeItems = ModOptionHelpers.unitSharingModeItems +local unitSharingCategoriesWithAll = ModOptionHelpers.unitSharingCategoriesWithAll +local unitSharingCategoriesWithNone = ModOptionHelpers.unitSharingCategoriesWithNone +local unitSharingCategoriesWithNoneAndAll = ModOptionHelpers.unitSharingCategoriesWithNoneAndAll + +--- Mod options owned by the sharing module, merged into the game's +--- modoptions.lua (same entry format; the section entry ships here too). + +return { + { + key = ModeEnums.ModeCategories.Transfer, + name = "Transfer", + desc = "What may pass between allied teams: units, resources, assistance", + type = "section", + weight = 6, + }, + + -- items must list the valid mode keys so SPADS/unitsync accept a value; the lobby enriches them from modules/transfer/modes/ + { + key = ModeEnums.ModOptions.TransferMode, + name = "Transfer Mode", + desc = "Overall transfer policy. Picking a mode sets the options below and locks the ones it depends on; pick Customize to change them yourself.", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.Modes.Enabled, + items = { + { key = ModeEnums.Modes.Enabled, name = "Enabled" }, + { key = ModeEnums.Modes.Disabled, name = "Disabled" }, + { key = ModeEnums.Modes.EasyTax, name = "Easy Tax" }, + { key = ModeEnums.Modes.Customize, name = "Customize" }, + { key = ModeEnums.Modes.TechCore, name = "Tech Core" }, + }, + }, + + { + key = "sub_header", + name = "-- Tech", + type = "subheader", + section = ModeEnums.ModeCategories.Transfer, + def = true, + }, + { + key = ModeEnums.ModOptions.TechBlocking, + name = "Tech Blocking", + desc = "Build Keystone buildings to accumulate tech points and unlock T2/T3 units.", + type = "bool", + section = ModeEnums.ModeCategories.Transfer, + def = false, + }, + { + key = ModeEnums.ModOptions.T2TechThreshold, + name = "Keystones per Player for Tech 2", + desc = "Keystones each player needs to unlock Tech 2. Multiplied by team size automatically.", + type = "number", + section = ModeEnums.ModeCategories.Transfer, + def = 1, + min = 0.5, + max = 20, + step = 0.5, + }, + { + key = ModeEnums.ModOptions.T3TechThreshold, + name = "Keystones per Player for Tech 3", + desc = "Keystones each player needs to unlock Tech 3. Multiplied by team size automatically.", + type = "number", + section = ModeEnums.ModeCategories.Transfer, + def = 1.5, + min = 0.5, + max = 20, + step = 0.5, + }, + + { + key = "sub_header", + name = "-- Units", + type = "subheader", + section = ModeEnums.ModeCategories.Transfer, + def = true, + }, + { + key = ModeEnums.ModOptions.UnitSharingMode, + name = "Unit Sharing", + desc = "Controls which units can be shared", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.UnitFilterCategory.All, + items = unitSharingModeItems, + }, + { + key = ModeEnums.ModOptions.UnitSharingModeAtT2, + name = "Unit Sharing activated by Tech 2", + desc = "Unit sharing mode that activates when team reaches Tech 2 (requires Tech Blocking)", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.UnitFilterCategory.None, + items = unitSharingCategoriesWithNoneAndAll, + }, + { + key = ModeEnums.ModOptions.UnitSharingModeAtT3, + name = "Unit Sharing activated by Tech 3", + desc = "Unit sharing mode that activates when team reaches Tech 3 (requires Tech Blocking)", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.UnitFilterCategory.None, + items = unitSharingCategoriesWithNoneAndAll, + }, + { + key = ModeEnums.ModOptions.UnitShareStunSeconds, + name = "Unit Share Stun Duration (seconds)", + desc = "Units matching the stun category are stunned for this many seconds when shared to an ally. Set to 0 to disable stun.", + type = "number", + section = ModeEnums.ModeCategories.Transfer, + def = 0, + min = 0, + max = 120, + step = 1, + column = 1, + }, + { + key = ModeEnums.ModOptions.UnitStunCategory, + name = "Unit Stun Category", + desc = "Which units are stunned when shared to an ally", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.UnitFilterCategory.Resource, + column = 2, + items = unitSharingCategoriesWithAll, + }, + { + key = ModeEnums.ModOptions.ConstructorBuildDelay, + name = "Constructor Build Delay (seconds)", + desc = "Shared mobile constructors have their build speed set to 0 for this many seconds. They can still move and queue builds, but make no progress until the delay expires. Set to 0 to disable.", + type = "number", + section = ModeEnums.ModeCategories.Transfer, + def = 0, + min = 0, + max = 120, + step = 1, + column = 1, + }, + + { + key = "sub_header", + name = "-- Resources", + type = "subheader", + desc = "", + section = ModeEnums.ModeCategories.Transfer, + def = true, + }, + { + key = ModeEnums.ModOptions.ResourceSharingEnabled, + name = "Resource Sharing Enabled", + desc = "Enable or disable all player-to-player resource sharing and overflow", + type = "bool", + section = ModeEnums.ModeCategories.Transfer, + def = true, + column = 1, + }, + { + key = ModeEnums.ModOptions.TaxResourceSharingAmount, + name = "Resource Sharing Tax Rate", + desc = "Taxes resource shared in any manner." .. "Set to [0] to turn off. Recommended: [0.3]. (Ranges: 0 - 0.99)", + type = "number", + def = 0, + min = 0, + max = 0.99, + step = 0.01, + section = ModeEnums.ModeCategories.Transfer, + column = 1, + }, + { + key = ModeEnums.ModOptions.TaxResourceSharingAmountAtT2, + name = "Tax Rate activated by Tech 2", + desc = "Tax rate when team reaches Tech 2. -1 means no override. (requires Tech Blocking)", + type = "number", + section = ModeEnums.ModeCategories.Transfer, + def = -1, + min = -1, + max = 1.0, + step = 0.01, + }, + { + key = ModeEnums.ModOptions.TaxResourceSharingAmountAtT3, + name = "Tax Rate activated by Tech 3", + desc = "Tax rate when team reaches Tech 3. -1 means no override. (requires Tech Blocking)", + type = "number", + section = ModeEnums.ModeCategories.Transfer, + def = -1, + min = -1, + max = 1.0, + step = 0.01, + }, + + { + key = "sub_header", + name = "-- Takes", + type = "subheader", + section = ModeEnums.ModeCategories.Transfer, + def = true, + }, + { + key = ModeEnums.ModOptions.TakeMode, + name = "Take Mode", + desc = "Controls what happens when a player uses /take on an inactive ally's units", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.TakeMode.Enabled, + items = { + { key = ModeEnums.TakeMode.Enabled, name = "Enabled", desc = "All units transfer immediately" }, + { key = ModeEnums.TakeMode.Disabled, name = "Disabled", desc = "Taking is not allowed" }, + { key = ModeEnums.TakeMode.StunDelay, name = "Stun Delay", desc = "Units transfer immediately but affected units are stunned for the delay duration" }, + { key = ModeEnums.TakeMode.TakeDelay, name = "Take Delay", desc = "Unaffected units transfer immediately; affected units require a second /take after the delay" }, + }, + }, + { + key = ModeEnums.ModOptions.TakeDelaySeconds, + name = "Take Delay (seconds)", + desc = "Duration of stun (Stun Delay mode) or wait before second /take (Take Delay mode)", + type = "number", + section = ModeEnums.ModeCategories.Transfer, + def = 30, + min = 0, + max = 120, + step = 1, + column = 1, + }, + { + key = ModeEnums.ModOptions.TakeDelayCategory, + name = "Take Delay Category", + desc = "Which units are affected by the take delay: stunned in Stun Delay mode, or held back in Take Delay mode", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.UnitCategory.Resource, + column = 2, + items = unitSharingCategoriesWithAll, + }, + { + key = "sub_header", + name = "-- Allies", + desc = "", + section = ModeEnums.ModeCategories.Transfer, + type = "subheader", + def = true, + }, + { + key = ModeEnums.ModOptions.AlliedAssistMode, + name = "Ally Assist", + desc = "Allow units to assist allied construction and repair", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.AlliedAssistMode.Enabled, + column = 1, + items = { + { key = ModeEnums.AlliedAssistMode.Enabled, name = "Enabled", desc = "Enabled" }, + { key = ModeEnums.AlliedAssistMode.Disabled, name = "Disabled", desc = "Disabled" }, + }, + }, + { + key = ModeEnums.ModOptions.AlliedUnitReclaimMode, + name = "Ally Unit Reclaim", + desc = "Allow reclaiming allied units and guarding allied units that can reclaim", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.AlliedUnitReclaimMode.Enabled, + column = 1, + items = { + { key = ModeEnums.AlliedUnitReclaimMode.Enabled, name = "Enabled", desc = "Enabled" }, + { key = ModeEnums.AlliedUnitReclaimMode.Disabled, name = "Disabled", desc = "Disabled" }, + }, + }, + { + key = ModeEnums.ModOptions.AllowPartialResurrection, + name = "Allow Partial Resurrection", + desc = "Allow resurrecting partly reclaimed wrecks (disabling prevents tax bypass)", + type = "list", + section = ModeEnums.ModeCategories.Transfer, + def = ModeEnums.AllowPartialResurrection.Enabled, + column = 1, + items = { + { key = ModeEnums.AllowPartialResurrection.Enabled, name = "Enabled", desc = "Enabled" }, + { key = ModeEnums.AllowPartialResurrection.Disabled, name = "Disabled", desc = "Disabled" }, + }, + }, +} diff --git a/modules/transfer/module.lua b/modules/transfer/module.lua new file mode 100644 index 00000000000..9f01e8404a8 --- /dev/null +++ b/modules/transfer/module.lua @@ -0,0 +1,10 @@ +--- Manifest for the transfer module: what may pass between allied teams. +--- Units, resources and an empty team's assets all move through one pipeline, +--- so a transfer is validated, priced and announced in one place regardless of +--- what is moving or who asked for it. +---@type ModuleManifestFile +return { + name = "transfer", + description = "Allied transfer: units, resources, take, and the tax on what flows", + requires = { "context", "tech", "construction", "economy" }, +} diff --git a/modules/transfer/policies/resource_transfer.lua b/modules/transfer/policies/resource_transfer.lua new file mode 100644 index 00000000000..ab6cc289906 --- /dev/null +++ b/modules/transfer/policies/resource_transfer.lua @@ -0,0 +1,68 @@ +--- Whether these two teams may move a resource, and on what terms. +--- +--- The stages were the early returns inside CalcResourcePolicy; naming them +--- is the whole change. Order is declaration order, the first non-nil result +--- wins, and Compute always returns — so "deny" and "here is the rate" are +--- the same kind of answer, produced by different stages. +--- +--- Every stage takes the resource. Today the rate is one number for all eco, +--- but that is a property of the modoption behind it, not of this pipeline: +--- a mode that prices metal apart from energy needs no new stage here. + +local SharedConfig = VFS.Include("modules/transfer/economy/shared_config.lua") +local Shared = VFS.Include("modules/transfer/resource/shared.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") + +local METAL = TransferEnums.ResourceType.METAL + +---@param ctx PolicyContext +---@param resourceType ResourceName +---@return ResourcePolicyResult +local function deny(ctx, resourceType) + return Shared.CreateDenyPolicy(ctx.senderTeamId, ctx.receiverTeamId, resourceType, ctx.springRepo) +end + +Policies.Pipeline() + :Gate("SharingDisabled", function(ctx, resourceType) + if not SharedConfig.isResourceSharingEnabled(ctx.springRepo) then + return deny(ctx, resourceType) + end + return nil + end) + -- Cheats bypass the alliance and empty-team gates but NOT the one above: + -- turning sharing off is a rule of the match, not a restriction to lift. + :Gate("NotAllied", function(ctx, resourceType) + if ctx.isCheatingEnabled then + return nil + end + if not ctx.areAlliedTeams and not Shared.IsNonPlayerTeam(ctx.springRepo, ctx.senderTeamId) then + return deny(ctx, resourceType) + end + return nil + end) + :Gate("ReceiverHasNoPlayers", function(ctx, resourceType) + if ctx.isCheatingEnabled then + return nil + end + local numActivePlayers = ctx.springRepo.GetTeamRulesParam(ctx.receiverTeamId, "numActivePlayers") + if numActivePlayers ~= nil and tonumber(numActivePlayers) == 0 then + return deny(ctx, resourceType) + end + return nil + end) + :Compute("RateAndCapacity", function(ctx, resourceType, rate, result) + local senderData, receiverData + if resourceType == METAL then + senderData = ctx.sender.metal + receiverData = ctx.receiver.metal + else + senderData = ctx.sender.energy + receiverData = ctx.receiver.energy + end + local taxedSendable = math.max(0, senderData.current) * (1 - rate) + local capacity = receiverData.storage - receiverData.current + Shared.CombineResourcePolicy(taxedSendable, rate, capacity, ctx.senderTeamId, ctx.receiverTeamId, resourceType, result) + result.techBlocking = ctx.ext and ctx.ext.techBlocking or nil + return result + end) + :Register() diff --git a/modules/transfer/policies/take.lua b/modules/transfer/policies/take.lua new file mode 100644 index 00000000000..8f6b94f78f8 --- /dev/null +++ b/modules/transfer/policies/take.lua @@ -0,0 +1,23 @@ +--- Whether a seat may be taken, and on what terms. +--- +--- /take moves an abandoned team's assets to whoever asks. The mode decides +--- if it is available at all, and whether the units arrive stunned or held +--- back for a while — the anti-abuse terms, without which taking a dying +--- ally is strictly better than fighting. +--- +--- One Compute, like unit_transfer and for the same reason: callers need the +--- delay terms in order to explain a refusal, so the answer is always a +--- record with a mode in it rather than an early nil. + +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") + +Policies.Pipeline() + :Compute("ModeAndDelay", function(ctx) + local modOptions = (ctx and ctx.modOptions) or Spring.GetModOptions() + return { + mode = modOptions[ModeEnums.ModOptions.TakeMode] or ModeEnums.TakeMode.Enabled, + delaySeconds = tonumber(modOptions[ModeEnums.ModOptions.TakeDelaySeconds]) or 30, + delayCategory = modOptions[ModeEnums.ModOptions.TakeDelayCategory] or ModeEnums.UnitCategory.Resource, + } + end) + :Register() diff --git a/modules/transfer/policies/unit_transfer.lua b/modules/transfer/policies/unit_transfer.lua new file mode 100644 index 00000000000..2752e294026 --- /dev/null +++ b/modules/transfer/policies/unit_transfer.lua @@ -0,0 +1,50 @@ +--- Whether these two teams may hand units over, and on what terms. +--- +--- Unlike resources there is no early deny here: the answer is always a +--- record, and canShare is a field in it, because the callers need the stun +--- and delay terms even when the transfer itself is refused (the tooltip +--- explains WHY it is refused). So this pipeline is one Compute and the gates +--- are the conditions inside it — named, and separable the moment a caller +--- wants a reason code instead of a boolean. + +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") + +local NONE = ModeEnums.UnitFilterCategory.None + +---@param ctx PolicyContext +---@param modes string[] +---@return boolean +local function sharingIsOn(ctx, modes) + if not ctx.areAlliedTeams then + return false + end + -- One mode, and it is None: the mode grammar's way of saying denied. + return not (#modes == 1 and modes[1] == NONE) +end + +---@param ctx PolicyContext +---@return boolean +local function receiverHasPlayers(ctx) + if ctx.isCheatingEnabled then + return true + end + local numActivePlayers = ctx.springRepo.GetTeamRulesParam(ctx.receiverTeamId, "numActivePlayers") + return numActivePlayers == nil or tonumber(numActivePlayers) ~= 0 +end + +Policies.Pipeline() + :Compute("ModeTermsAndReach", function(ctx) + local modOptions = ctx.springRepo.GetModOptions() + local modes = ctx.unitSharingModes or { modOptions.unit_sharing_mode or NONE } + return { + canShare = sharingIsOn(ctx, modes) and receiverHasPlayers(ctx), + senderTeamId = ctx.senderTeamId, + receiverTeamId = ctx.receiverTeamId, + sharingModes = modes, + stunSeconds = tonumber(modOptions[ModeEnums.ModOptions.UnitShareStunSeconds]) or 0, + stunCategory = modOptions[ModeEnums.ModOptions.UnitStunCategory] or ModeEnums.UnitFilterCategory.Resource, + buildDelaySeconds = tonumber(modOptions[ModeEnums.ModOptions.ConstructorBuildDelay]) or 0, + techBlocking = ctx.ext and ctx.ext.techBlocking or nil, + } + end) + :Register() diff --git a/modules/transfer/policy_bundle.lua b/modules/transfer/policy_bundle.lua new file mode 100644 index 00000000000..2c431884b80 --- /dev/null +++ b/modules/transfer/policy_bundle.lua @@ -0,0 +1,107 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local ModeBuilder = VFS.Include("modules/mode_builder.lua") + +local Opt = ModeEnums.ModOptions + +-- Option keys for tier-varying policies (base, .AtT2, .AtT3). +local UNIT_KEY = { [0] = Opt.UnitSharingMode, [2] = Opt.UnitSharingModeAtT2, [3] = Opt.UnitSharingModeAtT3 } +local TAX_KEY = { [0] = Opt.TaxResourceSharingAmount, [2] = Opt.TaxResourceSharingAmountAtT2, [3] = Opt.TaxResourceSharingAmountAtT3 } + +-- A mode is a named bundle of policies; modOptions is the serialization the +-- lobby/SPADS transport understands. Each policy serializes only the options it +-- owns: structural choices take the structure lock, numeric dials the dial lock. +local Serializers = { + ["unit.allow"] = function(p, lock) + return { [UNIT_KEY[p.tier or 0]] = { value = p.category or ModeEnums.UnitFilterCategory.All, locked = lock.structure } } + end, + ["unit.deny"] = function(p, lock) + return { [UNIT_KEY[p.tier or 0]] = { value = ModeEnums.UnitFilterCategory.None, locked = lock.structure } } + end, + ["unit.stun"] = function(p, lock) + return { + [Opt.UnitShareStunSeconds] = { value = p.seconds, locked = lock.dial }, + [Opt.UnitStunCategory] = { value = p.category, locked = lock.structure }, + } + end, + ["resource.allow"] = function(_p, lock) + return { [Opt.ResourceSharingEnabled] = { value = true, locked = lock.structure } } + end, + ["resource.deny"] = function(_p, lock) + return { [Opt.ResourceSharingEnabled] = { value = false, locked = lock.structure } } + end, + ["resource.tax"] = function(p, lock) + -- The policy takes the resource, but the transport does not yet: there + -- is one tax modoption per tier and none per resource. Rather than + -- write a metal rate into the key that prices both, say so. + assert(p.category == nil, + '.Tax(Transfer.Resources.' .. tostring(p.category) .. ', ...) cannot be serialized yet:' + .. ' the tax modoptions are per tier, not per resource. Tax Transfer.Resources for now.') + return { [TAX_KEY[p.tier or 0]] = { value = p.rate, locked = lock.dial, ui = p.ui } } + end, + ["assist.allow"] = function(_p, lock) + return { [Opt.AlliedAssistMode] = { value = ModeEnums.AlliedAssistMode.Enabled, locked = lock.structure } } + end, + ["assist.deny"] = function(_p, lock) + return { [Opt.AlliedAssistMode] = { value = ModeEnums.AlliedAssistMode.Disabled, locked = lock.structure } } + end, + ["reclaim.allow"] = function(_p, lock) + return { [Opt.AlliedUnitReclaimMode] = { value = ModeEnums.AlliedUnitReclaimMode.Enabled, locked = lock.structure } } + end, + ["reclaim.deny"] = function(_p, lock) + return { [Opt.AlliedUnitReclaimMode] = { value = ModeEnums.AlliedUnitReclaimMode.Disabled, locked = lock.structure } } + end, + ["resurrect.allow"] = function(_p, lock) + return { [Opt.AllowPartialResurrection] = { value = ModeEnums.AllowPartialResurrection.Enabled, locked = lock.structure } } + end, + ["resurrect.deny"] = function(_p, lock) + return { [Opt.AllowPartialResurrection] = { value = ModeEnums.AllowPartialResurrection.Disabled, locked = lock.structure } } + end, + ["take.allow"] = function(_p, lock) + return { [Opt.TakeMode] = { value = ModeEnums.TakeMode.Enabled, locked = lock.structure } } + end, + ["take.deny"] = function(_p, lock) + return { [Opt.TakeMode] = { value = ModeEnums.TakeMode.Disabled, locked = lock.structure } } + end, + ["take.stun"] = function(_p, lock) + return { [Opt.TakeMode] = { value = ModeEnums.TakeMode.StunDelay, locked = lock.structure } } + end, + ["take.defer"] = function(_p, lock) + return { [Opt.TakeMode] = { value = ModeEnums.TakeMode.TakeDelay, locked = lock.structure } } + end, + ["take.delay"] = function(p, lock) + return { + [Opt.TakeDelaySeconds] = { value = p.seconds, locked = lock.dial }, + [Opt.TakeDelayCategory] = { value = p.category, locked = lock.structure }, + } + end, + ["build.delay"] = function(p, lock) + return { [Opt.ConstructorBuildDelay] = { value = p.seconds, locked = lock.dial } } + end, + ["tech.gate"] = function(p, lock) + return { + [Opt.TechBlocking] = { value = true, locked = lock.structure }, + [Opt.T2TechThreshold] = { value = p.t2, locked = lock.dial }, + [Opt.T3TechThreshold] = { value = p.t3, locked = lock.dial }, + } + end, + ["tech.open"] = function(p, lock) + return { + [Opt.TechBlocking] = { value = false, locked = lock.structure }, + [Opt.T2TechThreshold] = { value = p.t2, locked = lock.dial }, + [Opt.T3TechThreshold] = { value = p.t3, locked = lock.dial }, + } + end, +} + +local M = {} + +M.Serializers = Serializers + +---Serialize a policy bundle to the modOptions table a ModeConfig declares. +---@param bundle ModePolicyRef[] +---@return table +function M.toModOptions(bundle) + return ModeBuilder.ToModOptions(Serializers, bundle) +end + +return M diff --git a/modules/transfer/resource/comms.lua b/modules/transfer/resource/comms.lua new file mode 100644 index 00000000000..fa65cbfe012 --- /dev/null +++ b/modules/transfer/resource/comms.lua @@ -0,0 +1,149 @@ +local TransferEnums = VFS.Include("modules/context/enums.lua") +local Cache = VFS.Include("modules/context/serialization.lua") +local FieldTypes = Cache.FieldTypes +local TechBlockingComms = VFS.Include("modules/tech/blocking_comms.lua") + +local Comms = { + ResourceCommunicationCase = TransferEnums.ResourceCommunicationCase, +} +Comms.__index = Comms + +--- Determine communication case from policy result +---@param policyResult ResourcePolicyResult +---@return integer +function Comms.DecideCommunicationCase(policyResult) + if policyResult.senderTeamId == policyResult.receiverTeamId then + return TransferEnums.ResourceCommunicationCase.OnSelf + end + if not policyResult.canShare then + return TransferEnums.ResourceCommunicationCase.OnDisabled + end + if policyResult.taxRate <= 0 then + return TransferEnums.ResourceCommunicationCase.OnTaxFree + end + return TransferEnums.ResourceCommunicationCase.OnTaxed +end + +---Format a number for UI display by flooring to whole numbers, mirroring ResourceTransfer's slider rounding. +---@param value number +---@return string +local function FormatNumberForUI(value) + if type(value) == "number" then + return tostring(math.floor(value)) + else + return tostring(value) + end +end + +Comms.FormatNumberForUI = FormatNumberForUI + +---@param policyResult ResourcePolicyResult +---@return TechUnlockInfo? +---@return TechBlockingContext? +local function getTaxUnlock(policyResult) + local tb = TechBlockingComms.fromPolicy(policyResult) + if not tb then + return nil, nil + end + + local opts = Spring.GetModOptions() + for scanLevel = tb.level + 1, 3 do + local raw = opts["tax_resource_sharing_amount_at_t" .. scanLevel] + local rate = tonumber(raw) + if rate and rate >= 0 then + local thresh = scanLevel == 2 and tb.t2Threshold or tb.t3Threshold + return { unlockLevel = scanLevel, unlockThreshold = thresh, unlockValue = raw }, tb + end + end + return nil, tb +end + +function Comms.TooltipText(policyResult) + local resBase = policyResult.resourceType == TransferEnums.ResourceType.METAL and "ui.playersList.shareMetal" or "ui.playersList.shareEnergy" + local pascalResourceType = policyResult.resourceType:gsub("^%l", string.upper) + local taxUnlock, tb = getTaxUnlock(policyResult) + local tree = taxUnlock and "tech" or "base" + local r = resBase .. "." .. tree + + local case = Comms.DecideCommunicationCase(policyResult) + if case == TransferEnums.ResourceCommunicationCase.OnSelf then + return Spring.I18N("ui.playersList.request" .. pascalResourceType) + elseif case == TransferEnums.ResourceCommunicationCase.OnTaxFree then + local i18nData = {} + if taxUnlock and tb then + i18nData.nextRate = FormatNumberForUI((tonumber(taxUnlock.unlockValue) or 0) * 100) + i18nData.nextTechLevel = taxUnlock.unlockLevel + i18nData.currentKeystones = tb.points + i18nData.requiredKeystones = taxUnlock.unlockThreshold + end + return Spring.I18N(r .. ".default", i18nData) + elseif case == TransferEnums.ResourceCommunicationCase.OnDisabled then + -- Force base tree for disabled (tech doesn't hard-block resources, so it's a game state reason) + return Spring.I18N(resBase .. ".base.disabled") + elseif case == TransferEnums.ResourceCommunicationCase.OnTaxed then + local i18nData = { + amountReceivable = FormatNumberForUI(policyResult.amountReceivable), + amountSendable = FormatNumberForUI(policyResult.amountSendable), + taxRatePercentage = FormatNumberForUI(policyResult.taxRate * 100), + } + if taxUnlock and tb then + i18nData.nextRate = FormatNumberForUI((tonumber(taxUnlock.unlockValue) or 0) * 100) + i18nData.nextTechLevel = taxUnlock.unlockLevel + i18nData.currentKeystones = tb.points + i18nData.requiredKeystones = taxUnlock.unlockThreshold + end + return Spring.I18N(r .. ".taxed", i18nData) + end +end + +Comms.SendTransferChatMessageProtocol = { + receivedAmount = FieldTypes.string, + sentAmount = FieldTypes.string, + taxRatePercentage = FieldTypes.string, +} + +Comms.SendTransferChatMessageProtocolHighlights = { + receivedAmount = true, + sentAmount = true, + taxRatePercentage = false, +} + +--- Send chat messages for completed resource transfers +---@param transferResult ResourceTransferResult +---@param policyResult ResourcePolicyResult +function Comms.SendTransferChatMessages(transferResult, policyResult) + if transferResult.sent > 0 then + local resourceType = policyResult.resourceType + local pascalResourceType = resourceType == TransferEnums.ResourceType.METAL and "Metal" or "Energy" + local case = Comms.DecideCommunicationCase(policyResult) + local chatParams = { + receivedAmount = math.floor(transferResult.received), + sentAmount = FormatNumberForUI(transferResult.sent), + taxRatePercentage = FormatNumberForUI(policyResult.taxRate * 100 + 0.5), + resourceType = resourceType, + } + + local key + if case == TransferEnums.ResourceCommunicationCase.OnTaxFree then + key = "ui.playersList.chat.sent" .. pascalResourceType + elseif case == TransferEnums.ResourceCommunicationCase.OnTaxed then + key = "ui.playersList.chat.sent" .. pascalResourceType .. "Taxed" + end + if not key then + return + end + + local serialized = Cache.Serialize(Comms.SendTransferChatMessageProtocol, chatParams) + -- Runs in synced (game_resource_transfer_controller). Spring.SendLuaRulesMsg only carries a + -- real playerID when sent from a player's client, so from synced game_message would resolve an + -- empty/wrong sender name and recipients' gui_chat would fail to classify it as an ally chat + -- line, leaking the raw i18n key. Instead route straight to game_message's unsynced "sendMsg" + -- action (dispatched by action name across gadgets), attributed to the sender team's player. + local _, senderPlayerID = Spring.GetTeamInfo(policyResult.senderTeamId, false) + if senderPlayerID and senderPlayerID >= 0 then + SendToUnsynced("sendMsg", senderPlayerID, key .. ":" .. serialized) + end + end +end + +return Comms diff --git a/modules/transfer/resource/shared.lua b/modules/transfer/resource/shared.lua new file mode 100644 index 00000000000..15a99f759fe --- /dev/null +++ b/modules/transfer/resource/shared.lua @@ -0,0 +1,169 @@ +local TransferEnums = VFS.Include("modules/context/enums.lua") +local PolicyShared = VFS.Include("modules/context/serialization.lua") +local Comms = VFS.Include("modules/transfer/resource/comms.lua") +local SharedConfig = VFS.Include("modules/transfer/economy/shared_config.lua") + +local Shared = Comms + +local FieldTypes = PolicyShared.FieldTypes + +-- Schema for the GUI's flattened per-player policy packing (gui_advplayerlist/policy.lua). +Shared.ResourcePolicyFields = { + resourceType = FieldTypes.string, + canShare = FieldTypes.boolean, + amountSendable = FieldTypes.number, + amountReceivable = FieldTypes.number, + taxedPortion = FieldTypes.number, + taxRate = FieldTypes.number, +} + +-- one factor record per (team, resource), O(teams); GetCachedPolicyResult rebuilds any pair on read via min(taxedSendable(sender), capacity(receiver)) +Shared.ResourceFactorFields = { + taxedSendable = FieldTypes.number, + taxRate = FieldTypes.number, + capacity = FieldTypes.number, + isNonPlayer = FieldTypes.boolean, + active = FieldTypes.boolean, +} + +---Rules-param key for a team's resource factor record (owner team = the rules-param team). +---@param resourceType ResourceName +---A team no human is playing: gaia, an AI, or a LuaAI seat. They are exempt +---from the alliance gate — the game moves resources for them. +---@param springRepo Spring +---@param teamId integer +---@return boolean +function Shared.IsNonPlayerTeam(springRepo, teamId) + if teamId == springRepo.GetGaiaTeamID() then + return true + end + local _name, _active, _spec, isAiTeam = springRepo.GetTeamInfo(teamId, false) + if isAiTeam then + return true + end + -- Spring.GetTeamLuaAI returns "" (not nil) for teams without a LuaAI, so guard both. + local luaAI = springRepo.GetTeamLuaAI and springRepo.GetTeamLuaAI(teamId) + return luaAI ~= nil and luaAI ~= "" +end + +---@return string +function Shared.MakeFactorKey(resourceType) + local policyType = resourceType == TransferEnums.ResourceType.METAL and TransferEnums.PolicyType.MetalTransfer or TransferEnums.PolicyType.EnergyTransfer + return policyType .. "_factor" +end + +---@param factor table +---@return string +function Shared.SerializeResourceFactor(factor) + return PolicyShared.Serialize(Shared.ResourceFactorFields, factor) +end + +---@param serialized string +---@return table +function Shared.DeserializeResourceFactor(serialized) + return PolicyShared.Deserialize(Shared.ResourceFactorFields, serialized) +end + +---combine sender + receiver factors into a ResourcePolicyResult (same math as CalcResourcePolicy) +---@param taxedSendable number sender factor +---@param taxRate number sender factor +---@param capacity number receiver factor +---@param senderTeamId integer +---@param receiverTeamId integer +---@param resourceType ResourceName +---@param result table? optional reusable result table +---@return ResourcePolicyResult +function Shared.CombineResourcePolicy(taxedSendable, taxRate, capacity, senderTeamId, receiverTeamId, resourceType, result) + result = result or {} + local taxedPortion = math.min(taxedSendable, capacity) + local amountSendable = taxedPortion + result.senderTeamId = senderTeamId + result.receiverTeamId = receiverTeamId + result.canShare = capacity > 0 and amountSendable > 0 + result.amountSendable = amountSendable + result.amountReceivable = capacity + result.taxedPortion = taxedPortion + result.taxRate = taxRate + result.resourceType = resourceType + return result +end + +---@param senderTeamId integer +---@param receiverTeamId integer +---@param resourceType ResourceName +---@param springApi Spring? +---@return ResourcePolicyResult +function Shared.CreateDenyPolicy(senderTeamId, receiverTeamId, resourceType, springApi) + ---@type ResourcePolicyResult + local result = { + senderTeamId = senderTeamId, + receiverTeamId = receiverTeamId, + canShare = false, + amountSendable = 0, + amountReceivable = 0, + taxedPortion = 0, + taxRate = 0, + resourceType = resourceType, + } + return result +end + +---@param policyResult ResourcePolicyResult +---@param desired number +---@return number received, number sent +function Shared.CalculateSenderTaxedAmount(policyResult, desired) + if desired <= 0 then + return 0, 0 + end + local r = policyResult.taxRate + if r >= 1.0 then + -- 100% tax means the resource cannot be sent (infinite cost) + return 0, 0 + end + local sent = desired / (1 - r) + return desired, sent +end + +---@param spring Spring +---@param teamId integer +---@param resourceType ResourceName +---@return table|nil factor record, or nil if not cached +local function readFactor(spring, teamId, resourceType) + local serialized = spring.GetTeamRulesParam(teamId, Shared.MakeFactorKey(resourceType)) + if serialized == nil then + return nil + end + return Shared.DeserializeResourceFactor(serialized) +end + +---rebuild (sender,receiver) policy from cached factors + live gates (gate order mirrors TryDenyPolicy); absent factors deny +---@param senderId integer +---@param receiverId integer +---@param resourceType ResourceName +---@param springApi Spring? +---@return ResourcePolicyResult +function Shared.GetCachedPolicyResult(senderId, receiverId, resourceType, springApi) + local spring = springApi or Spring + if not SharedConfig.isResourceSharingEnabled(spring) then + return Shared.CreateDenyPolicy(senderId, receiverId, resourceType, spring) + end + + local senderFactor = readFactor(spring, senderId, resourceType) + local receiverFactor = readFactor(spring, receiverId, resourceType) + if not senderFactor or not receiverFactor then + return Shared.CreateDenyPolicy(senderId, receiverId, resourceType, spring) + end + + if not spring.IsCheatingEnabled() then + if not spring.AreTeamsAllied(senderId, receiverId) and not senderFactor.isNonPlayer then + return Shared.CreateDenyPolicy(senderId, receiverId, resourceType, spring) + end + if not receiverFactor.active then + return Shared.CreateDenyPolicy(senderId, receiverId, resourceType, spring) + end + end + + return Shared.CombineResourcePolicy(senderFactor.taxedSendable, senderFactor.taxRate, receiverFactor.capacity, senderId, receiverId, resourceType) +end + +return Shared diff --git a/modules/transfer/resource/synced.lua b/modules/transfer/resource/synced.lua new file mode 100644 index 00000000000..55aa274a76d --- /dev/null +++ b/modules/transfer/resource/synced.lua @@ -0,0 +1,153 @@ +local ModuleHandler = VFS.Include("modules/module_handler.lua") +local SharedConfig = VFS.Include("modules/transfer/economy/shared_config.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local Comms = VFS.Include("modules/transfer/resource/comms.lua") +local Shared = VFS.Include("modules/transfer/resource/shared.lua") +local WaterfillSolver = VFS.Include("modules/economy/lib/waterfill_solver.lua") +local PolicyEvents = VFS.Include("modules/context/policy_events.lua") + +local ResourceType = TransferEnums.ResourceType +local METAL = ResourceType.METAL +local ENERGY = ResourceType.ENERGY + +local Gadgets = { + SendTransferChatMessages = Comms.SendTransferChatMessages, +} + +--- Execute a resource transfer using received-unit desiredAmount capped by policy limits +---@param ctx ResourceTransferContext +---@return ResourceTransferResult +function Gadgets.ResourceTransfer(ctx) + local policyResult = ctx.policyResult + local desiredAmount = ctx.desiredAmount + if (not policyResult or not policyResult.canShare) or (not desiredAmount or desiredAmount <= 0) then + ---@type ResourceTransferResult + return { + success = false, + sent = 0, + received = 0, + senderTeamId = ctx.senderTeamId, + receiverTeamId = ctx.receiverTeamId, + policyResult = policyResult, + } + end + + local received, sent = Shared.CalculateSenderTaxedAmount(policyResult, desiredAmount) + + local springRepo = ctx.springRepo + local resourceType = policyResult.resourceType + -- deduct via SetTeamResource; AddTeamResource clamps its amount to >= 0 + local senderCurrent = springRepo.GetTeamResources(ctx.senderTeamId, resourceType) or 0 + springRepo.SetTeamResource(ctx.senderTeamId, resourceType, math.max(0, senderCurrent - sent)) + springRepo.AddTeamResource(ctx.receiverTeamId, resourceType, received) + + ---@type ResourceTransferResult + local result = { + success = true, + sent = sent, + received = received, + senderTeamId = ctx.senderTeamId, + receiverTeamId = ctx.receiverTeamId, + policyResult = policyResult, + } + + return result +end + +local policyResultPool = {} ---@type table + +---resolve a context's effective resource tax rate in [0,1] (sender tech tax, else base) +---@param ctx PolicyContext +---@param resourceType ResourceName +---@return number +local function resolveEffectiveRate(ctx, resourceType) + -- The resource is a parameter even though today's modoption is one number + -- for all eco: pricing metal apart from energy changes what feeds this, + -- not who asks it. + local perResource = ctx.taxRates and ctx.taxRates[resourceType] + local taxRate = (perResource or ctx.taxRate or SharedConfig.getTaxConfig(ctx.springRepo)) --[[@as number]] + return math.min(taxRate, 1) +end + +---@param ctx PolicyContext +---@param resourceType ResourceName +---@return ResourcePolicyResult +function Gadgets.CalcResourcePolicy(ctx, resourceType) + -- Gates and compute are declared one named stage each in + -- policies/resource_transfer.lua. The scratch table stays here: this runs + -- per resource per tick, and the pool is why it does not allocate. + local result = policyResultPool[resourceType] + if not result then + result = {} --[[@as ResourcePolicyResult]] -- filled by the Compute stage + policyResultPool[resourceType] = result + end + local pipeline = ModuleHandler.LoadPolicies("transfer").resource_transfer + return ModuleHandler.Evaluate(pipeline, ctx, resourceType, resolveEffectiveRate(ctx, resourceType), result) +end + +---@param springRepo Spring +---@param teamId integer +---@return boolean +local function teamActive(springRepo, teamId) + local n = springRepo.GetTeamRulesParam(teamId, "numActivePlayers") + if n == nil then + return true + end + return tonumber(n) ~= 0 +end + +---Compute and cache one team's resource factor record for a single resource. +---@param springRepo Spring +---@param teamId integer +---@param resourceType ResourceName +---@param ctx PolicyContext self-context (sender==receiver==teamId) so the enricher resolves the team's tax +function Gadgets.CacheTeamFactor(springRepo, teamId, resourceType, ctx) + local data = (resourceType == METAL) and ctx.sender.metal or ctx.sender.energy + local effectiveRate = resolveEffectiveRate(ctx, resourceType) + local isNonPlayer = Shared.IsNonPlayerTeam(springRepo, teamId) + local active = teamActive(springRepo, teamId) + local factor = { + taxedSendable = math.max(0, data.current) * (1 - effectiveRate), + taxRate = effectiveRate, + capacity = data.storage - data.current, + isNonPlayer = isNonPlayer, + active = active, + } + springRepo.SetTeamRulesParam(teamId, Shared.MakeFactorKey(resourceType), Shared.SerializeResourceFactor(factor)) + -- Policy fields only; live amounts (taxedSendable/capacity) would fire every economy tick. + local signature = string.format("%s|%s|%s", tostring(effectiveRate), tostring(active), tostring(isNonPlayer)) + local category = (resourceType == METAL) and TransferEnums.PolicyType.MetalTransfer or TransferEnums.PolicyType.EnergyTransfer + PolicyEvents.NotifyIfChanged(teamId, category, signature) +end + +---refresh the per-team resource factor cache (O(teams), factors are independent); pairs reconstructed on read +---@param springRepo Spring +---@param frame number +---@param lastUpdate number +---@param updateRate number +---@param contextFactory table +---@return number lastUpdate New last update frame +function Gadgets.UpdatePolicyCache(springRepo, frame, lastUpdate, updateRate, contextFactory) + if frame < lastUpdate + updateRate then + return lastUpdate + end + + contextFactory.clearResourceCache() + + local allTeams = springRepo.GetTeamList() + for _, teamId in ipairs(allTeams) do + local ctx = contextFactory.policy(teamId, teamId) + Gadgets.CacheTeamFactor(springRepo, teamId, METAL, ctx) + Gadgets.CacheTeamFactor(springRepo, teamId, ENERGY, ctx) + end + + return frame +end + +---@param springRepo Spring +---@param teamsList TeamResourceData[] +function Gadgets.WaterfillSolve(springRepo, teamsList) + return WaterfillSolver.Solve(springRepo, teamsList, SharedConfig.getTeamTaxRate) +end + +return Gadgets diff --git a/modules/transfer/resource/tax.lua b/modules/transfer/resource/tax.lua new file mode 100644 index 00000000000..8f15195ea0e --- /dev/null +++ b/modules/transfer/resource/tax.lua @@ -0,0 +1,39 @@ +--- What a transfer costs. The rate varies by the sender's tech tier, so +--- this reads the tier tech owns and prices it here, where the transfer is. + +local TechTier = VFS.Include("modules/tech/tier.lua") + +local TAX_KEY = "tax_resource_sharing_amount" + +local Tax = {} + +---@param teamId integer +---@param opts table? modoptions (defaults to springRepo.GetModOptions()) +---@param springRepo Spring? defaults to Spring (pass a repo to stay testable) +---@return number +function Tax.GetTaxRate(teamId, opts, springRepo) + springRepo = springRepo or Spring + opts = opts or springRepo.GetModOptions() + local base = tonumber(opts[TAX_KEY]) or 0 + local level = tonumber(springRepo.GetTeamRulesParam(teamId, "tech_level") or 1) or 1 + local rate = tonumber(TechTier.resolveByTechLevel(opts, TAX_KEY, level)) + if not rate or rate < 0 then + rate = base + end + if rate < 0 then + rate = 0 + elseif rate > 1 then + rate = 1 + end + return rate +end + +-- True if any tier (base, _at_t2, _at_t3) configures a positive tax. +---@param opts table? modoptions +---@return boolean +function Tax.AnyTaxConfigured(opts) + opts = opts or Spring.GetModOptions() + return (tonumber(opts[TAX_KEY]) or 0) > 0 or (tonumber(opts[TAX_KEY .. "_at_t2"]) or 0) > 0 or (tonumber(opts[TAX_KEY .. "_at_t3"]) or 0) > 0 +end + +return Tax diff --git a/modules/transfer/spec/actions_spec.lua b/modules/transfer/spec/actions_spec.lua new file mode 100644 index 00000000000..a7cd3dacbf8 --- /dev/null +++ b/modules/transfer/spec/actions_spec.lua @@ -0,0 +1,90 @@ +--- transfer publishes its capabilities as declared actions: the framework's +--- actions/ slot, loaded by ModuleHandler, validate before execute. Until +--- these files existed the loader had no callers at all. + +local ModuleHandler = VFS.Include("modules/module_handler.lua") + +describe("transfer actions", function() + local registry + + setup(function() + ModuleHandler.ResetCaches() + registry = ModuleHandler.LoadActions("transfer") + end) + + it("declares units and resources", function() + assert.is_table(registry.byName.units) + assert.is_table(registry.byName.resources) + end) + + it("declares give and give_resources, the two that skip policy", function() + assert.is_table(registry.byName.give) + assert.is_table(registry.byName.give_resources) + end) + + it("every action registers an execute", function() + for _, action in ipairs(registry.list) do + assert.is_function(action.execute, action.name .. " must register execute") + end + end) + + it("validate refuses a team sharing with itself", function() + local allowed, reason = registry.byName.units.validate({ from = 1, to = 1, unitIDs = { 7 } }) + assert.is_false(allowed) + assert.is_truthy(tostring(reason):find("itself", 1, true)) + end) + + it("validate refuses an empty unit list", function() + assert.is_false(registry.byName.units.validate({ from = 0, to = 1, unitIDs = {} })) + end) + + -- An action file runs in the registrar's environment, so the pipeline + -- entry point cannot be read from GG there; it arrives in the request. + -- Without this the action would fail only in a running game. + it("validate refuses when the controller has not handed its pipeline over", function() + local allowed, reason = registry.byName.units.validate({ from = 0, to = 1, unitIDs = { 7 } }) + assert.is_false(allowed) + assert.is_truthy(tostring(reason):find("initialized", 1, true)) + end) + + it("give_resources refuses a team handing to itself", function() + local allowed, reason = registry.byName.give_resources.validate({ + from = 2, to = 2, resource = "metal", amount = 5, + add = function() end, take = function() end, + }) + assert.is_false(allowed) + assert.is_truthy(tostring(reason):find("itself", 1, true)) + end) + + it("give_resources moves the whole amount, both sides, untaxed", function() + local moves = {} + local record = function(team, resource, delta) + moves[#moves + 1] = { team = team, resource = resource, delta = delta } + end + local moved = registry.byName.give_resources.execute({ + from = 1, to = 2, resource = "metal", amount = 40, add = record, take = record, + }) + assert.are.equal(40, moved) + assert.are.same({ + { team = 1, resource = "metal", delta = -40 }, + { team = 2, resource = "metal", delta = 40 }, + }, moves) + end) + + it("resources validate refuses without a send function", function() + local allowed, reason = registry.byName.resources.validate({ + from = 0, to = 1, resource = "metal", amount = 5, + }) + assert.is_false(allowed) + assert.is_truthy(tostring(reason):find("initialized", 1, true)) + end) + + it("validate refuses a resource that is not metal or energy", function() + local allowed = registry.byName.resources.validate({ from = 0, to = 1, resource = "ore", amount = 5 }) + assert.is_false(allowed) + end) + + it("validate refuses a non-positive amount", function() + assert.is_false(registry.byName.resources.validate({ from = 0, to = 1, resource = "metal", amount = 0 })) + end) +end) diff --git a/modules/transfer/spec/economy/share_stats_spec.lua b/modules/transfer/spec/economy/share_stats_spec.lua new file mode 100644 index 00000000000..486612c4c63 --- /dev/null +++ b/modules/transfer/spec/economy/share_stats_spec.lua @@ -0,0 +1,63 @@ +local ShareStats = VFS.Include("modules/transfer/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/modules/transfer/spec/gadgets/game_resource_transfer_controller_spec.lua b/modules/transfer/spec/gadgets/game_resource_transfer_controller_spec.lua new file mode 100644 index 00000000000..15c4618a7e0 --- /dev/null +++ b/modules/transfer/spec/gadgets/game_resource_transfer_controller_spec.lua @@ -0,0 +1,207 @@ +local Builders = VFS.Include("spec/builders/index.lua") +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local ResourceTypes = VFS.Include("gamedata/resource_types.lua") +local SharedConfig = VFS.Include("modules/transfer/economy/shared_config.lua") +local WaterfillSolver = VFS.Include("modules/economy/lib/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, SharedConfig.getTeamTaxRate) + + 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, SharedConfig.getTeamTaxRate) + + local teamAMetal, teamBMetal ---@type EconomyTeamResult, EconomyTeamResult + 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, SharedConfig.getTeamTaxRate) + + local teamAMetal, teamBMetal ---@type EconomyTeamResult, EconomyTeamResult + 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, SharedConfig.getTeamTaxRate) + + 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("modules/transfer/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/modules/transfer/spec/modes/easy_tax_spec.lua b/modules/transfer/spec/modes/easy_tax_spec.lua new file mode 100644 index 00000000000..d3441b3d40f --- /dev/null +++ b/modules/transfer/spec/modes/easy_tax_spec.lua @@ -0,0 +1,162 @@ +---@type Builders +local Builders = VFS.Include("spec/builders/index.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local H = Builders.Mode + +local easyTaxMode = VFS.Include("modules/transfer/modes/easy_tax.lua") + +local sender = Builders.Team:new():Human() +local receiver = Builders.Team:new():Human() +local spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):WithAlliance(sender.id, receiver.id, true):WithTeamRulesParam(receiver.id, "numActivePlayers", 1):WithTeamRulesParam(sender.id, "numActivePlayers", 1) + +describe("Easy Tax mode #policy", function() + describe("with default settings (30% tax)", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + metalResult = H.buildModeResult(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.METAL) + energyResult = H.buildModeResult(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.ENERGY) + end) + + it("should ALLOW sharing of both resources", function() + assert.equal(true, metalResult.canShare) + assert.equal(true, energyResult.canShare) + end) + + it("should apply 30% tax rate", function() + assert.equal(0.30, metalResult.taxRate) + assert.equal(0.30, energyResult.taxRate) + end) + + it("should compute sendable amount with 30% tax overhead", function() + -- sender=500, rate=0.30: 500 * 0.70 = 350 + assert.equal(350, metalResult.amountSendable) + assert.equal(350, energyResult.amountSendable) + end) + end) + + describe("when receiver is full", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(1000):WithEnergy(1000) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + metalResult = H.buildModeResult(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.METAL) + energyResult = H.buildModeResult(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.ENERGY) + end) + + it("should NOT allow sharing", function() + assert.equal(false, metalResult.canShare) + assert.equal(false, energyResult.canShare) + end) + + it("should set amount sendable to 0", function() + assert.equal(0, metalResult.amountSendable) + assert.equal(0, energyResult.amountSendable) + end) + end) + + describe("when sender is empty", function() + it("should NOT allow sharing", function() + sender:WithMetal(0):WithEnergy(0) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local metalResult = H.buildModeResult(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.METAL) + local energyResult = H.buildModeResult(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.ENERGY) + + assert.equal(false, metalResult.canShare) + assert.equal(false, energyResult.canShare) + end) + end) + + describe("when sender has less than desired", function() + it("should cap sendable amount to sender budget after tax", function() + sender:WithMetal(50):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local policy = H.snapshotResult(H.buildModeResult(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.METAL)) + + assert.equal(true, policy.canShare) + -- sender=50, rate=0.30: 50 * 0.70 = 35 + assert.equal(35, policy.amountSendable) + end) + end) + + describe("transfer action with 30% tax", function() + it("should deduct more from sender than receiver gets", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.METAL, 100) + + assert.is_true(result.success) + assert.equal(100, result.received) + assert.is_true(result.sent > result.received) + end) + + it("should transfer energy with tax overhead", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.ENERGY, 200) + + assert.is_true(result.success) + assert.equal(200, result.received) + -- 30% tax: sender pays 200 / 0.7 ≈ 285.71 + assert.is_near(285.71, result.sent, 0.1) + end) + + it("should not transfer when receiver is full", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(1000):WithEnergy(1000) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, easyTaxMode, sender, receiver, TransferEnums.ResourceType.METAL, 100) + + assert.equal(false, result.success) + assert.equal(0, result.sent) + assert.equal(0, result.received) + end) + end) +end) + +describe("Easy Tax mode policy bundle", function() + local ModeEnums = VFS.Include("modules/context/mode_enums.lua") + + it("keeps the enum key", function() + assert.equal(ModeEnums.Modes.EasyTax, easyTaxMode.key) + end) + + it("serializes to the exact modOptions the literal preset declared", function() + assert.same({ + [ModeEnums.ModOptions.UnitSharingMode] = { value = ModeEnums.UnitFilterCategory.All, locked = true }, + [ModeEnums.ModOptions.UnitShareStunSeconds] = { value = 30, locked = false }, + [ModeEnums.ModOptions.UnitStunCategory] = { value = ModeEnums.UnitFilterCategory.Resource, locked = true }, + [ModeEnums.ModOptions.ConstructorBuildDelay] = { value = 30, locked = false }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { value = true, locked = true }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { value = 0.30, locked = false }, + [ModeEnums.ModOptions.AlliedAssistMode] = { value = ModeEnums.AlliedAssistMode.Enabled, locked = true }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { value = ModeEnums.AlliedUnitReclaimMode.Enabled, locked = true }, + [ModeEnums.ModOptions.AllowPartialResurrection] = { value = ModeEnums.AllowPartialResurrection.Enabled, locked = true }, + [ModeEnums.ModOptions.TakeMode] = { value = ModeEnums.TakeMode.StunDelay, locked = true }, + [ModeEnums.ModOptions.TakeDelaySeconds] = { value = 30, locked = false }, + [ModeEnums.ModOptions.TakeDelayCategory] = { value = ModeEnums.UnitCategory.Resource, locked = true }, + }, easyTaxMode.modOptions) + end) +end) diff --git a/modules/transfer/spec/modes/tech_core_spec.lua b/modules/transfer/spec/modes/tech_core_spec.lua new file mode 100644 index 00000000000..c4cbc032f25 --- /dev/null +++ b/modules/transfer/spec/modes/tech_core_spec.lua @@ -0,0 +1,312 @@ +---@type Builders +local Builders = VFS.Include("spec/builders/index.lua") +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local H = Builders.Mode + +local techCoreMode = VFS.Include("modules/transfer/modes/tech_core.lua") + +-- Derive expected tax from the preset so value tweaks don't break the spec. +local baseTax = techCoreMode.modOptions[ModeEnums.ModOptions.TaxResourceSharingAmount].value +local taxAtT2 = techCoreMode.modOptions[ModeEnums.ModOptions.TaxResourceSharingAmountAtT2].value +local taxAtT3 = techCoreMode.modOptions[ModeEnums.ModOptions.TaxResourceSharingAmountAtT3].value +local SENDER_POOL = 500 + +local function techCoreEnricher(techLevel, modeConfig) + local baseTax = modeConfig.modOptions[ModeEnums.ModOptions.TaxResourceSharingAmount].value + local taxAtT2 = modeConfig.modOptions[ModeEnums.ModOptions.TaxResourceSharingAmountAtT2] and modeConfig.modOptions[ModeEnums.ModOptions.TaxResourceSharingAmountAtT2].value + local taxAtT3 = modeConfig.modOptions[ModeEnums.ModOptions.TaxResourceSharingAmountAtT3] and modeConfig.modOptions[ModeEnums.ModOptions.TaxResourceSharingAmountAtT3].value + + return function(ctx) + local effectiveTax = baseTax + if techLevel >= 3 and taxAtT3 then + effectiveTax = taxAtT3 + elseif techLevel >= 2 and taxAtT2 then + effectiveTax = taxAtT2 + end + ctx.taxRate = effectiveTax + ctx.ext = ctx.ext or {} + ctx.ext.techBlocking = { + level = techLevel, + points = techLevel - 1, + t2Threshold = modeConfig.modOptions[ModeEnums.ModOptions.T2TechThreshold].value, + t3Threshold = modeConfig.modOptions[ModeEnums.ModOptions.T3TechThreshold].value, + } + end +end + +local sender = Builders.Team:new():Human() +local receiver = Builders.Team:new():Human() +local spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):WithAlliance(sender.id, receiver.id, true):WithTeamRulesParam(receiver.id, "numActivePlayers", 1):WithTeamRulesParam(sender.id, "numActivePlayers", 1) + +describe("Tech Core mode #policy", function() + describe("at T1 (base tax rate)", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + metalResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(1, techCoreMode)) + energyResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.ENERGY, techCoreEnricher(1, techCoreMode)) + end) + + it("should ALLOW sharing", function() + assert.equal(true, metalResult.canShare) + assert.equal(true, energyResult.canShare) + end) + + it("should use the base tax rate", function() + assert.equal(baseTax, metalResult.taxRate) + assert.equal(baseTax, energyResult.taxRate) + end) + + it("should compute sendable amount with the base tax", function() + assert.equal(SENDER_POOL * (1 - baseTax), metalResult.amountSendable) + assert.equal(SENDER_POOL * (1 - baseTax), energyResult.amountSendable) + end) + + it("should attach techBlocking context at level 1", function() + assert.is_not_nil(metalResult.techBlocking) + assert.equal(1, assert(metalResult.techBlocking).level) + end) + end) + + describe("at T2 (reduced tax rate)", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + metalResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(2, techCoreMode)) + energyResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.ENERGY, techCoreEnricher(2, techCoreMode)) + end) + + it("should ALLOW sharing", function() + assert.equal(true, metalResult.canShare) + assert.equal(true, energyResult.canShare) + end) + + it("should use the T2 tax rate", function() + assert.equal(taxAtT2, metalResult.taxRate) + assert.equal(taxAtT2, energyResult.taxRate) + end) + + it("should compute more sendable resources than T1", function() + assert.equal(SENDER_POOL * (1 - taxAtT2), metalResult.amountSendable) + assert.equal(SENDER_POOL * (1 - taxAtT2), energyResult.amountSendable) + end) + + it("should attach techBlocking context at level 2", function() + assert.is_not_nil(metalResult.techBlocking) + assert.equal(2, assert(metalResult.techBlocking).level) + end) + end) + + describe("at T3 (lowest tax rate)", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + metalResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(3, techCoreMode)) + energyResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.ENERGY, techCoreEnricher(3, techCoreMode)) + end) + + it("should ALLOW sharing", function() + assert.equal(true, metalResult.canShare) + assert.equal(true, energyResult.canShare) + end) + + it("should use the T3 tax rate", function() + assert.equal(taxAtT3, metalResult.taxRate) + assert.equal(taxAtT3, energyResult.taxRate) + end) + + it("should compute most sendable resources at T3", function() + assert.equal(SENDER_POOL * (1 - taxAtT3), metalResult.amountSendable) + assert.equal(SENDER_POOL * (1 - taxAtT3), energyResult.amountSendable) + end) + + it("should attach techBlocking context at level 3", function() + assert.is_not_nil(metalResult.techBlocking) + assert.equal(3, assert(metalResult.techBlocking).level) + end) + end) + + describe("tax progression across tech levels", function() + it("should have decreasing tax rates from T1 to T3", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local t1 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(1, techCoreMode))) + local t2 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(2, techCoreMode))) + local t3 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(3, techCoreMode))) + + assert.is_true(t1.taxRate > t2.taxRate) + assert.is_true(t2.taxRate > t3.taxRate) + end) + + it("should increase sendable amount as tech level increases", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local t1 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(1, techCoreMode))) + local t2 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(2, techCoreMode))) + local t3 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(3, techCoreMode))) + + assert.is_true(t1.amountSendable < t2.amountSendable) + assert.is_true(t2.amountSendable < t3.amountSendable) + end) + end) + + describe("without enricher (fallback behavior)", function() + it("should fall back to base mod option tax rate when no enricher is registered", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL) + + assert.equal(baseTax, result.taxRate) + assert.is_nil(result.techBlocking) + end) + end) + + describe("when receiver is full at T3", function() + it("should NOT allow sharing despite low tax rate", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(1000):WithEnergy(1000) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local metalResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(3, techCoreMode)) + local energyResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.ENERGY, techCoreEnricher(3, techCoreMode)) + + assert.equal(false, metalResult.canShare) + assert.equal(false, energyResult.canShare) + end) + end) + + describe("techBlocking extension data", function() + it("should expose correct thresholds and points at each level", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local t1 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(1, techCoreMode))) + assert.equal(1, t1.techBlocking.level) + assert.equal(0, t1.techBlocking.points) + assert.equal(1, t1.techBlocking.t2Threshold) + assert.equal(1.5, t1.techBlocking.t3Threshold) + + local t2 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(2, techCoreMode))) + assert.equal(2, t2.techBlocking.level) + assert.equal(1, t2.techBlocking.points) + + local t3 = H.snapshotResult(H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(3, techCoreMode))) + assert.equal(3, t3.techBlocking.level) + assert.equal(2, t3.techBlocking.points) + end) + end) + + describe("when sender is empty at any tech level", function() + it("should NOT allow sharing at T2", function() + sender:WithMetal(0):WithEnergy(0) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local metalResult = H.buildModeResult(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, techCoreEnricher(2, techCoreMode)) + + assert.equal(false, metalResult.canShare) + end) + end) + + describe("transfer action at different tech levels", function() + it("should cost less to send at T3 than T1 for the same received amount", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local t1Result = H.buildModeTransfer(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, 100, techCoreEnricher(1, techCoreMode)) + + sender:WithMetal(500) + receiver:WithMetal(0) + + local t3Result = H.buildModeTransfer(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, 100, techCoreEnricher(3, techCoreMode)) + + assert.is_true(t1Result.success) + assert.is_true(t3Result.success) + assert.equal(100, t1Result.received) + assert.equal(100, t3Result.received) + assert.is_true(t1Result.sent > t3Result.sent) + end) + + it("should transfer with T2 tax rate", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, 100, techCoreEnricher(2, techCoreMode)) + + assert.is_true(result.success) + assert.equal(100, result.received) + -- sender pays received / (1 - tax) to land 100 after the T2 tax + assert.is_near(100 / (1 - taxAtT2), result.sent, 0.1) + end) + + it("should not transfer when receiver is full at T3", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(1000):WithEnergy(1000) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, techCoreMode, sender, receiver, TransferEnums.ResourceType.METAL, 100, techCoreEnricher(3, techCoreMode)) + + assert.equal(false, result.success) + assert.equal(0, result.sent) + assert.equal(0, result.received) + end) + end) +end) + +describe("Tech Core mode policy bundle", function() + it("keeps the enum key", function() + assert.equal(ModeEnums.Modes.TechCore, techCoreMode.key) + end) + + it("serializes to the exact modOptions the literal preset declared", function() + assert.same({ + [ModeEnums.ModOptions.TechBlocking] = { value = true, locked = true }, + [ModeEnums.ModOptions.T2TechThreshold] = { value = 1, locked = false }, + [ModeEnums.ModOptions.T3TechThreshold] = { value = 1.5, locked = false }, + [ModeEnums.ModOptions.UnitSharingMode] = { value = ModeEnums.UnitFilterCategory.None, locked = true }, + [ModeEnums.ModOptions.UnitSharingModeAtT2] = { value = ModeEnums.UnitFilterCategory.Constructors, locked = true }, + [ModeEnums.ModOptions.UnitSharingModeAtT3] = { value = ModeEnums.UnitFilterCategory.None, locked = true }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { value = true, locked = true }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { value = 0.6, locked = false }, + [ModeEnums.ModOptions.TaxResourceSharingAmountAtT2] = { value = 0.5, locked = false }, + [ModeEnums.ModOptions.TaxResourceSharingAmountAtT3] = { value = 0.4, locked = false }, + [ModeEnums.ModOptions.AlliedAssistMode] = { value = ModeEnums.AlliedAssistMode.Enabled, locked = true }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { value = ModeEnums.AlliedUnitReclaimMode.Enabled, locked = true }, + [ModeEnums.ModOptions.AllowPartialResurrection] = { value = ModeEnums.AllowPartialResurrection.Disabled, locked = true }, + [ModeEnums.ModOptions.TakeMode] = { value = ModeEnums.TakeMode.TakeDelay, locked = true }, + [ModeEnums.ModOptions.TakeDelaySeconds] = { value = 60, locked = false }, + [ModeEnums.ModOptions.TakeDelayCategory] = { value = ModeEnums.UnitCategory.Resource, locked = true }, + }, techCoreMode.modOptions) + end) +end) diff --git a/modules/transfer/spec/modes/transfer_customize_spec.lua b/modules/transfer/spec/modes/transfer_customize_spec.lua new file mode 100644 index 00000000000..d1235b19af9 --- /dev/null +++ b/modules/transfer/spec/modes/transfer_customize_spec.lua @@ -0,0 +1,34 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") + +local customizeMode = VFS.Include("modules/transfer/modes/customize.lua") + +describe("Customize mode policy bundle", function() + it("keeps the enum key and retains values", function() + assert.equal(ModeEnums.Modes.Customize, customizeMode.key) + assert.equal(true, customizeMode.retainValues) + end) + + it("serializes to the exact modOptions the literal preset declared", function() + assert.same({ + [ModeEnums.ModOptions.TechBlocking] = { value = false, locked = false }, + [ModeEnums.ModOptions.T2TechThreshold] = { value = 1, locked = false }, + [ModeEnums.ModOptions.T3TechThreshold] = { value = 1.5, locked = false }, + [ModeEnums.ModOptions.UnitSharingMode] = { value = ModeEnums.UnitFilterCategory.All, locked = false }, + [ModeEnums.ModOptions.UnitSharingModeAtT2] = { value = ModeEnums.UnitFilterCategory.None, locked = false }, + [ModeEnums.ModOptions.UnitSharingModeAtT3] = { value = ModeEnums.UnitFilterCategory.None, locked = false }, + [ModeEnums.ModOptions.UnitShareStunSeconds] = { value = 0, locked = false }, + [ModeEnums.ModOptions.UnitStunCategory] = { value = ModeEnums.UnitFilterCategory.Resource, locked = false }, + [ModeEnums.ModOptions.ConstructorBuildDelay] = { value = 0, locked = false }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { value = true, locked = false }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { value = 0, locked = false }, + [ModeEnums.ModOptions.TaxResourceSharingAmountAtT2] = { value = -1, locked = false }, + [ModeEnums.ModOptions.TaxResourceSharingAmountAtT3] = { value = -1, locked = false }, + [ModeEnums.ModOptions.AlliedAssistMode] = { value = ModeEnums.AlliedAssistMode.Enabled, locked = false }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { value = ModeEnums.AlliedUnitReclaimMode.Enabled, locked = false }, + [ModeEnums.ModOptions.AllowPartialResurrection] = { value = ModeEnums.AllowPartialResurrection.Enabled, locked = false }, + [ModeEnums.ModOptions.TakeMode] = { value = ModeEnums.TakeMode.Enabled, locked = false }, + [ModeEnums.ModOptions.TakeDelaySeconds] = { value = 30, locked = false }, + [ModeEnums.ModOptions.TakeDelayCategory] = { value = ModeEnums.UnitCategory.Resource, locked = false }, + }, customizeMode.modOptions) + end) +end) diff --git a/modules/transfer/spec/modes/transfer_disabled_spec.lua b/modules/transfer/spec/modes/transfer_disabled_spec.lua new file mode 100644 index 00000000000..8c021fe47fa --- /dev/null +++ b/modules/transfer/spec/modes/transfer_disabled_spec.lua @@ -0,0 +1,99 @@ +---@type Builders +local Builders = VFS.Include("spec/builders/index.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local H = Builders.Mode + +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") + +local noSharingMode = VFS.Include("modules/transfer/modes/disabled.lua") + +local sender = Builders.Team:new():Human() +local receiver = Builders.Team:new():Human() +local spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):WithAlliance(sender.id, receiver.id, true):WithTeamRulesParam(receiver.id, "numActivePlayers", 1):WithTeamRulesParam(sender.id, "numActivePlayers", 1) + +describe("Transfer Disabled mode #policy", function() + describe("resource policy", function() + it("should deny metal sharing", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local metalResult = H.buildModeResult(spring, noSharingMode, sender, receiver, TransferEnums.ResourceType.METAL) + + assert.equal(false, metalResult.canShare) + end) + + it("should deny energy sharing", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local energyResult = H.buildModeResult(spring, noSharingMode, sender, receiver, TransferEnums.ResourceType.ENERGY) + + assert.equal(false, energyResult.canShare) + end) + end) + + describe("transfer action", function() + it("should fail metal transfer with zero sent and received", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, noSharingMode, sender, receiver, TransferEnums.ResourceType.METAL, 100) + + assert.equal(false, result.success) + assert.equal(0, result.sent) + assert.equal(0, result.received) + end) + + it("should fail energy transfer with zero sent and received", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, noSharingMode, sender, receiver, TransferEnums.ResourceType.ENERGY, 100) + + assert.equal(false, result.success) + assert.equal(0, result.sent) + assert.equal(0, result.received) + end) + end) + + describe("policy bundle", function() + it("serializes to the exact modOptions the literal preset declared", function() + assert.same({ + [ModeEnums.ModOptions.UnitSharingMode] = { value = ModeEnums.UnitFilterCategory.None, locked = true }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { value = false, locked = true }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { value = 0.30, locked = false, ui = "hidden" }, + [ModeEnums.ModOptions.AlliedAssistMode] = { value = ModeEnums.AlliedAssistMode.Disabled, locked = true }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { value = ModeEnums.AlliedUnitReclaimMode.Disabled, locked = true }, + [ModeEnums.ModOptions.TakeMode] = { value = ModeEnums.TakeMode.Disabled, locked = false }, + }, noSharingMode.modOptions) + end) + + it("DSL chain builds the same ModeConfig as the explicit table form", function() + local Bundle = VFS.Include("modules/transfer/policy_bundle.lua") + local policies = { + { "unit.deny" }, + { "resource.deny" }, + { "resource.tax", rate = 0.30, locked = false, ui = "hidden" }, + { "assist.deny" }, + { "reclaim.deny" }, + { "take.deny", locked = false }, + } + local explicit = { + key = ModeEnums.Modes.Disabled, + category = ModeEnums.ModeCategories.Transfer, + name = "Disabled", + desc = "No sharing of any kind: no resources, no units, no assisting or reclaiming an ally, no /take. Most sharing options are locked.", + allowRanked = true, + policies = policies, + modOptions = Bundle.toModOptions(policies), + } + for field, expected in pairs(explicit) do + assert.same(expected, noSharingMode[field], field) + end + end) + end) +end) diff --git a/modules/transfer/spec/modes/transfer_enabled_spec.lua b/modules/transfer/spec/modes/transfer_enabled_spec.lua new file mode 100644 index 00000000000..4b540ebdc1d --- /dev/null +++ b/modules/transfer/spec/modes/transfer_enabled_spec.lua @@ -0,0 +1,119 @@ +---@type Builders +local Builders = VFS.Include("spec/builders/index.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local H = Builders.Mode + +local enabledMode = VFS.Include("modules/transfer/modes/enabled.lua") + +local sender = Builders.Team:new():Human() +local receiver = Builders.Team:new():Human() +local spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):WithAlliance(sender.id, receiver.id, true):WithTeamRulesParam(receiver.id, "numActivePlayers", 1):WithTeamRulesParam(sender.id, "numActivePlayers", 1) + +describe("Transfer Enabled mode #policy", function() + describe("with default settings (zero tax, zero thresholds)", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + metalResult = H.buildModeResult(spring, enabledMode, sender, receiver, TransferEnums.ResourceType.METAL) + energyResult = H.buildModeResult(spring, enabledMode, sender, receiver, TransferEnums.ResourceType.ENERGY) + end) + + it("should ALLOW sharing of both resources", function() + assert.equal(true, metalResult.canShare) + assert.equal(true, energyResult.canShare) + end) + + it("should have zero tax rate", function() + assert.equal(0, metalResult.taxRate) + assert.equal(0, energyResult.taxRate) + end) + + it("should have sendable equal to full sender budget (no tax overhead)", function() + assert.equal(500, metalResult.amountSendable) + assert.equal(500, energyResult.amountSendable) + end) + + it("should have receivable equal to full receiver capacity", function() + assert.equal(1000, metalResult.amountReceivable) + assert.equal(1000, energyResult.amountReceivable) + end) + end) + + describe("when receiver is full", function() + it("should NOT allow sharing", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(1000):WithEnergy(1000) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local metalResult = H.buildModeResult(spring, enabledMode, sender, receiver, TransferEnums.ResourceType.METAL) + local energyResult = H.buildModeResult(spring, enabledMode, sender, receiver, TransferEnums.ResourceType.ENERGY) + + assert.equal(false, metalResult.canShare) + assert.equal(false, energyResult.canShare) + end) + end) + + describe("transfer action (untaxed)", function() + it("should transfer with sent == received (no tax deducted)", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, enabledMode, sender, receiver, TransferEnums.ResourceType.METAL, 200) + + assert.is_true(result.success) + assert.equal(200, result.received) + assert.equal(200, result.sent) + end) + + it("should transfer energy with sent == received", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(0):WithEnergy(0) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, enabledMode, sender, receiver, TransferEnums.ResourceType.ENERGY, 300) + + assert.is_true(result.success) + assert.equal(300, result.received) + assert.equal(300, result.sent) + end) + + it("should not transfer when receiver is full", function() + sender:WithMetal(500):WithEnergy(500) + receiver:WithMetal(1000):WithEnergy(1000) + receiver:WithMetalStorage(1000):WithEnergyStorage(1000) + + local result = H.buildModeTransfer(spring, enabledMode, sender, receiver, TransferEnums.ResourceType.METAL, 100) + + assert.equal(false, result.success) + assert.equal(0, result.sent) + assert.equal(0, result.received) + end) + end) +end) + +describe("Enabled mode policy bundle", function() + local ModeEnums = VFS.Include("modules/context/mode_enums.lua") + + it("keeps the enum key", function() + assert.equal(ModeEnums.Modes.Enabled, enabledMode.key) + end) + + it("serializes to the exact modOptions the literal preset declared", function() + assert.same({ + [ModeEnums.ModOptions.UnitSharingMode] = { value = ModeEnums.UnitFilterCategory.All, locked = true }, + [ModeEnums.ModOptions.ResourceSharingEnabled] = { value = true, locked = true }, + [ModeEnums.ModOptions.TaxResourceSharingAmount] = { value = 0.0, locked = true, ui = "hidden" }, + [ModeEnums.ModOptions.AlliedAssistMode] = { value = ModeEnums.AlliedAssistMode.Enabled, locked = true }, + [ModeEnums.ModOptions.AlliedUnitReclaimMode] = { value = ModeEnums.AlliedUnitReclaimMode.Enabled, locked = true }, + [ModeEnums.ModOptions.TakeMode] = { value = ModeEnums.TakeMode.Enabled, locked = true }, + }, enabledMode.modOptions) + end) +end) diff --git a/modules/transfer/spec/resource/factor_cache_spec.lua b/modules/transfer/spec/resource/factor_cache_spec.lua new file mode 100644 index 00000000000..896100dbd16 --- /dev/null +++ b/modules/transfer/spec/resource/factor_cache_spec.lua @@ -0,0 +1,87 @@ +---@type Builders +local Builders = VFS.Include("spec/builders/index.lua") +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local ContextFactoryModule = VFS.Include("modules/context/context_factory.lua") +local ResourceTransfer = VFS.Include("modules/transfer/resource/synced.lua") +local ResourceShared = VFS.Include("modules/transfer/resource/shared.lua") +local SharedConfig = VFS.Include("modules/transfer/economy/shared_config.lua") + +local METAL = TransferEnums.ResourceType.METAL + +-- The cache stores one per-team factor record per resource; GetCachedPolicyResult +-- reconstructs any (sender,receiver) pair from those factors plus live gates. +describe("resource policy cache (per-team factors) #policy", function() + local sender, receiver, enemy ---@type TeamBuilder, TeamBuilder, TeamBuilder + local spring ---@type SpringSyncedBuilder + + before_each(function() + SharedConfig.resetCache() + sender = Builders.Team:new():Human():WithMetal(500):WithEnergy(500) + receiver = Builders.Team:new():Human():WithMetal(0):WithEnergy(0) + enemy = Builders.Team:new():Human():WithMetal(0):WithEnergy(0) + spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):WithTeam(enemy):WithAlliance(sender.id, receiver.id, true):WithAlliance(receiver.id, sender.id, true):WithModOption(ModeEnums.ModOptions.TaxResourceSharingAmount, 0):WithTeamRulesParam(sender.id, "numActivePlayers", 1):WithTeamRulesParam(receiver.id, "numActivePlayers", 1):WithTeamRulesParam(enemy.id, "numActivePlayers", 1) + end) + + -- Build the mock and populate the factor cache for all teams. + local function populate() + local springApi = spring:Build() + local contextFactory = ContextFactoryModule.create(springApi) + ResourceTransfer.UpdatePolicyCache(springApi, 1000, -1000, 30, contextFactory) + return springApi, contextFactory + end + + it("reconstructs an allied pair identically to a direct CalcResourcePolicy", function() + local springApi, contextFactory = populate() + local cached = ResourceShared.GetCachedPolicyResult(sender.id, receiver.id, METAL, springApi) + local direct = ResourceTransfer.CalcResourcePolicy(contextFactory.policy(sender.id, receiver.id), METAL) + assert.equal(direct.canShare, cached.canShare) + assert.equal(direct.amountSendable, cached.amountSendable) + assert.equal(direct.amountReceivable, cached.amountReceivable) + assert.equal(direct.taxedPortion, cached.taxedPortion) + assert.equal(direct.taxRate, cached.taxRate) + end) + + it("stores per-team factor records, not per-pair entries", function() + local springApi = populate() + local metalKey = ResourceShared.MakeFactorKey(METAL) + assert.is_not_nil(springApi.GetTeamRulesParam(sender.id, metalKey)) + assert.is_not_nil(springApi.GetTeamRulesParam(receiver.id, metalKey)) + assert.is_not_nil(springApi.GetTeamRulesParam(enemy.id, metalKey)) + end) + + it("denies a cross-alliance pair via the live gate (no cached deny entry)", function() + local springApi = populate() + local cached = ResourceShared.GetCachedPolicyResult(sender.id, enemy.id, METAL, springApi) + assert.equal(false, cached.canShare) + end) + + it("denies when the receiver has no active players", function() + spring:WithTeamRulesParam(receiver.id, "numActivePlayers", 0) + local springApi = populate() + local cached = ResourceShared.GetCachedPolicyResult(sender.id, receiver.id, METAL, springApi) + assert.equal(false, cached.canShare) + end) + + it("allows a cross-alliance pair when cheating is enabled", function() + local springApi = populate() + springApi.IsCheatingEnabled = function() + return true + end + local cached = ResourceShared.GetCachedPolicyResult(sender.id, enemy.id, METAL, springApi) + assert.equal(true, cached.canShare) + end) + + it("denies when factors are absent (cache not yet populated)", function() + local springApi = spring:Build() + local cached = ResourceShared.GetCachedPolicyResult(sender.id, receiver.id, METAL, springApi) + assert.equal(false, cached.canShare) + end) + + it("denies every pair when resource sharing is disabled", function() + spring:WithModOption(ModeEnums.ModOptions.ResourceSharingEnabled, false) + local springApi = populate() + local cached = ResourceShared.GetCachedPolicyResult(sender.id, receiver.id, METAL, springApi) + assert.equal(false, cached.canShare) + end) +end) diff --git a/modules/transfer/spec/resource/transfer_spec.lua b/modules/transfer/spec/resource/transfer_spec.lua new file mode 100644 index 00000000000..c659b97cf1f --- /dev/null +++ b/modules/transfer/spec/resource/transfer_spec.lua @@ -0,0 +1,510 @@ +---@type Builders +local Builders = VFS.Include("spec/builders/index.lua") +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local ContextFactoryModule = VFS.Include("modules/context/context_factory.lua") +local ResourceTransfer = VFS.Include("modules/transfer/resource/synced.lua") +local ResourceShared = VFS.Include("modules/transfer/resource/shared.lua") +local SharedConfig = VFS.Include("modules/transfer/economy/shared_config.lua") + +local sender = Builders.Team:new():Human() +local receiver = Builders.Team:new():Human() + +local function buildResourceResult(spring, taxRate, sender, receiver, resourceType) + local springApi = spring:Build() + springApi.GetModOptions = function() + return { + tax_resource_sharing_amount = tostring(taxRate), + } + end + SharedConfig.resetCache() + local ctx = ContextFactoryModule.create(springApi).policy(sender.id, receiver.id) + return ResourceTransfer.CalcResourcePolicy(ctx, resourceType) +end + +local spring = Builders + .Spring + .new() + :WithTeam(sender) + :WithTeam(receiver) + :WithAlliance(sender.id, receiver.id, true) + -- set in-game by cmd_idle_players.lua + :WithTeamRulesParam(receiver.id, "numActivePlayers", 1) + :WithTeamRulesParam(sender.id, "numActivePlayers", 1) + +describe(ModeEnums.ModOptions.TaxResourceSharingAmount .. " #policy", function() + local taxRate = 0.5 + + describe("simple taxation", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + sender:WithEnergy(500):WithMetal(500) + receiver:WithEnergy(0):WithMetal(0) + + spring:WithModOption(ModeEnums.ModOptions.TaxResourceSharingAmount, taxRate) + + metalResult = buildResourceResult(spring, taxRate, sender, receiver, TransferEnums.ResourceType.METAL) + energyResult = buildResourceResult(spring, taxRate, sender, receiver, TransferEnums.ResourceType.ENERGY) + end) + + it("should ALLOW sharing of both METAL and ENERGY", function() + assert.equal(metalResult.canShare, true) + assert.equal(energyResult.canShare, true) + end) + + it("should cap amount sendable (in receivable units) and account for tax overhead", function() + assert.equal(250, metalResult.amountSendable) + assert.equal(250, energyResult.amountSendable) + end) + + it("should cap the amount receivable by the receivers storage capacity", function() + assert.equal(1000, metalResult.amountReceivable) + assert.equal(1000, energyResult.amountReceivable) + end) + + it("should expose the tax rate", function() + assert.equal(taxRate, metalResult.taxRate) + assert.equal(taxRate, energyResult.taxRate) + end) + end) + + describe("when receiver is full", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + sender:WithEnergy(500):WithMetal(500) + receiver:WithEnergy(1000):WithMetal(1000) + + metalResult = buildResourceResult(spring, taxRate, sender, receiver, TransferEnums.ResourceType.METAL) + energyResult = buildResourceResult(spring, taxRate, sender, receiver, TransferEnums.ResourceType.ENERGY) + end) + + it("should NOT allow sharing when receiver is full", function() + assert.equal(metalResult.canShare, false) + assert.equal(energyResult.canShare, false) + end) + + it("should set amount sendable to 0", function() + assert.equal(0, metalResult.amountSendable) + assert.equal(0, energyResult.amountSendable) + end) + end) + + describe("when receiver capacity is below the taxed sendable amount", function() + it("should cap amount sendable to the receiver capacity", function() + sender:WithMetal(1000) + receiver:WithMetal(980) + local metalResult = buildResourceResult(spring, taxRate, sender, receiver, TransferEnums.ResourceType.METAL) + assert.equal(metalResult.amountSendable, 20) + end) + end) + + describe("rate = 0.7, receiver capacity 300, sender 1000", function() + ---@type ResourcePolicyResult + local energyResult + local testTaxRate = 0.7 + + before_each(function() + spring:WithModOption(ModeEnums.ModOptions.TaxResourceSharingAmount, testTaxRate) + sender:WithEnergy(1000) + receiver:WithEnergyStorage(1000):WithEnergy(700) + + energyResult = buildResourceResult(spring, testTaxRate, sender, receiver, TransferEnums.ResourceType.ENERGY) + end) + + it("should have amountReceivable set to receiver capacity and amountSendable == 300", function() + assert.equal(300, energyResult.amountReceivable) + assert.equal(300, energyResult.amountSendable) + end) + end) + + describe("sender 1000, rate = 0.7, receiver capacity 300", function() + ---@type ResourcePolicyResult + local energyResult + local testTaxRate = 0.7 + + before_each(function() + spring:WithModOption(ModeEnums.ModOptions.TaxResourceSharingAmount, testTaxRate) + sender:WithEnergy(1000) + receiver:WithEnergyStorage(1000):WithEnergy(700) + + energyResult = buildResourceResult(spring, testTaxRate, sender, receiver, TransferEnums.ResourceType.ENERGY) + end) + + it("should enable sharing", function() + assert.equal(true, energyResult.canShare) + end) + + it("should have a receivable amount set to the receiver's capacity and amountSendable == 300", function() + assert.equal(300, energyResult.amountReceivable) + assert.equal(300, energyResult.amountSendable) + end) + end) + + describe("when taxation is disabled", function() + ---@type ResourcePolicyResult + local metalResult + ---@type ResourcePolicyResult + local energyResult + + before_each(function() + spring:WithModOption(ModeEnums.ModOptions.TaxResourceSharingAmount, 0) + receiver:WithEnergyStorage(1000):WithEnergy(0) + receiver:WithMetalStorage(1000):WithMetal(0) + end) + + it("should not tax metal transfers", function() + sender:WithMetal(100) + metalResult = buildResourceResult(spring, 0, sender, receiver, TransferEnums.ResourceType.METAL) + assert.equal(1000, metalResult.amountReceivable) + assert.equal(100, metalResult.amountSendable) + + sender:WithMetal(500) + metalResult = buildResourceResult(spring, 0, sender, receiver, TransferEnums.ResourceType.METAL) + assert.equal(1000, metalResult.amountReceivable) + assert.equal(500, metalResult.amountSendable) + end) + + it("should not tax energy transfers", function() + sender:WithEnergy(100) + energyResult = buildResourceResult(spring, 0, sender, receiver, TransferEnums.ResourceType.ENERGY) + assert.equal(1000, energyResult.amountReceivable) + assert.equal(100, energyResult.amountSendable) + + sender:WithEnergy(500) + energyResult = buildResourceResult(spring, 0, sender, receiver, TransferEnums.ResourceType.ENERGY) + assert.equal(1000, energyResult.amountReceivable) + assert.equal(500, energyResult.amountSendable) + end) + end) +end) + +describe("ResourceTransfer #action", function() + local sender = Builders.Team:new():Human() + local receiver = Builders.Team:new():Human() + local spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):Build() + + describe("basic resource transfer", function() + it("should transfer metal without overhead when the tax rate is zero", function() + ---@type ResourceTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.MetalTransfer, + resourceType = TransferEnums.ResourceType.METAL, + desiredAmount = 100, + isCheatingEnabled = false, + springRepo = spring, + areAlliedTeams = true, + ext = {}, + sender = { + metal = { + resourceType = "metal", + excess = 0, + current = 1000, + storage = 1000, + pull = 0, + income = 0, + expense = 0, + shareSlider = 0, + sent = 0, + received = 0, + }, + energy = { + resourceType = "energy", + excess = 0, + current = 1000, + storage = 1000, + pull = 0, + income = 0, + expense = 0, + shareSlider = 0, + sent = 0, + received = 0, + }, + }, + receiver = { + metal = { + resourceType = "metal", + excess = 0, + current = 500, + storage = 1000, + pull = 0, + income = 0, + expense = 0, + shareSlider = 0, + sent = 0, + received = 0, + }, + energy = { + resourceType = "energy", + excess = 0, + current = 500, + storage = 1000, + pull = 0, + income = 0, + expense = 0, + shareSlider = 0, + sent = 0, + received = 0, + }, + }, + policyResult = { + canShare = true, + resourceType = TransferEnums.ResourceType.METAL, + amountSendable = 500, + amountReceivable = 500, + taxRate = 0, + taxedPortion = 500, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + local result = ResourceTransfer.ResourceTransfer(ctx) + + assert.is_true(result.success) + assert.equal(100, result.sent) + assert.equal(100, result.received) + end) + + it("debits the sender and credits the receiver in engine team state", function() + local s = Builders.Team:new():Human():WithMetal(1000):WithMetalStorage(1000) + local r = Builders.Team:new():Human():WithMetal(500):WithMetalStorage(1000) + local spr = Builders.Spring.new():WithTeam(s):WithTeam(r):Build() + + ---@type ResourceTransferContext + local ctx = { + senderTeamId = s.id, + receiverTeamId = r.id, + policyType = TransferEnums.PolicyType.MetalTransfer, + resourceType = TransferEnums.ResourceType.METAL, + desiredAmount = 100, + isCheatingEnabled = false, + springRepo = spr, + areAlliedTeams = true, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 500, storage = 1000, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 500, storage = 1000, shareSlider = 0, sent = 0, received = 0 }, + }, + policyResult = { + canShare = true, + resourceType = TransferEnums.ResourceType.METAL, + amountSendable = 500, + amountReceivable = 500, + taxRate = 0, + taxedPortion = 100, + senderTeamId = s.id, + receiverTeamId = r.id, + }, + } + + local result = ResourceTransfer.ResourceTransfer(ctx) + + assert.is_true(result.success) + assert.equal(100, result.sent) + assert.equal(100, result.received) + assert.equal(900, (spr.GetTeamResources(s.id, TransferEnums.ResourceType.METAL))) + assert.equal(600, (spr.GetTeamResources(r.id, TransferEnums.ResourceType.METAL))) + end) + + it("should apply tax overhead to the sender cost", function() + ---@type ResourceTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.MetalTransfer, + resourceType = TransferEnums.ResourceType.METAL, + desiredAmount = 200, + isCheatingEnabled = false, + springRepo = spring, + areAlliedTeams = true, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 500, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 500, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + policyResult = { + canShare = true, + resourceType = TransferEnums.ResourceType.METAL, + amountSendable = 500, + amountReceivable = 500, + taxRate = 0.3, + taxedPortion = 500, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + local result = ResourceTransfer.ResourceTransfer(ctx) + + assert.is_true(result.success) + -- sent = 200/0.7 = 285.71 + assert.is_near(285.71, result.sent, 0.1) + assert.is_near(200, result.received, 0.1) + end) + + it("should handle 100% tax rate", function() + ---@type ResourceTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.MetalTransfer, + resourceType = TransferEnums.ResourceType.METAL, + desiredAmount = 200, + isCheatingEnabled = false, + springRepo = spring, + areAlliedTeams = true, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 500, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 500, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + policyResult = { + canShare = true, + resourceType = TransferEnums.ResourceType.METAL, + amountSendable = 500, + amountReceivable = 500, + taxRate = 1.0, + taxedPortion = 500, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + local result = ResourceTransfer.ResourceTransfer(ctx) + + assert.is_true(result.success) + -- nothing is sendable at 100% tax (infinite cost) + assert.equal(0, result.sent) + assert.equal(0, result.received) + end) + + it("should limit transfer to amountSendable", function() + --- @type ResourceTransferContext + local ctx = { + desiredAmount = 300, + policyType = TransferEnums.PolicyType.MetalTransfer, + isCheatingEnabled = false, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + resourceType = TransferEnums.ResourceType.METAL, + ext = {}, + policyResult = { + canShare = true, + resourceType = TransferEnums.ResourceType.METAL, + amountSendable = 300, + amountReceivable = 9999, + taxRate = 0.2, + taxedPortion = 300, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + springRepo = spring, + areAlliedTeams = true, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 500, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 500, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + } + + local result = ResourceTransfer.ResourceTransfer(ctx) + + assert.is_true(result.success) + -- sent = 300/0.8 = 375 + assert.is_near(375, result.sent, 0.1) + assert.is_near(300, result.received, 0.1) + end) + end) + + describe("CalculateSenderTaxedAmount helper", function() + it("caps by amountSendable and amountReceivable and computes sender cost", function() + local policyResult = { + resourceType = TransferEnums.ResourceType.ENERGY, + amountSendable = 820, + amountReceivable = 1000, + taxRate = 0.3, + } + + local desired = 820 + local desiredCapped = math.min(desired, policyResult.amountSendable, policyResult.amountReceivable) + local received, sent = ResourceShared.CalculateSenderTaxedAmount(policyResult, desiredCapped) + -- cost = 820/0.7 = 1171.43 + assert.is_near(1171.43, sent, 0.01) + assert.equal(820, received) + end) + + it("caps desired by amountReceivable when it is lower", function() + local policyResult = { + resourceType = TransferEnums.ResourceType.ENERGY, + amountSendable = 500, + amountReceivable = 300, + taxRate = 0.7, + } + local desiredCapped = math.min(999, policyResult.amountSendable, policyResult.amountReceivable) + local received, sent = ResourceShared.CalculateSenderTaxedAmount(policyResult, desiredCapped) + assert.equal(300, received) + assert.is_near(1000, sent, 0.01) -- 300/(1-0.7) + end) + end) +end) + +describe("Resource comms #comms", function() + describe("DecideCommunicationCase", function() + it("should return OnSelf when sender equals receiver", function() + local policy = { senderTeamId = 1, receiverTeamId = 1, canShare = true, taxRate = 0.3 } + assert.equal(TransferEnums.ResourceCommunicationCase.OnSelf, ResourceShared.DecideCommunicationCase(policy)) + end) + + it("should return OnTaxFree when tax rate is zero", function() + local policy = { senderTeamId = 1, receiverTeamId = 2, canShare = true, taxRate = 0 } + assert.equal(TransferEnums.ResourceCommunicationCase.OnTaxFree, ResourceShared.DecideCommunicationCase(policy)) + end) + + it("should return OnTaxed when taxed", function() + local policy = { senderTeamId = 1, receiverTeamId = 2, canShare = true, taxRate = 0.3 } + assert.equal(TransferEnums.ResourceCommunicationCase.OnTaxed, ResourceShared.DecideCommunicationCase(policy)) + end) + + it("should return OnDisabled when canShare is false", function() + local policy = { senderTeamId = 1, receiverTeamId = 2, canShare = false, taxRate = 0 } + assert.equal(TransferEnums.ResourceCommunicationCase.OnDisabled, ResourceShared.DecideCommunicationCase(policy)) + end) + end) + + describe("FormatNumberForUI", function() + it("should floor numbers to whole values", function() + assert.equal("285", ResourceShared.FormatNumberForUI(285.71)) + assert.equal("100", ResourceShared.FormatNumberForUI(100.99)) + assert.equal("0", ResourceShared.FormatNumberForUI(0.5)) + end) + + it("should pass through non-number values", function() + assert.equal("hello", ResourceShared.FormatNumberForUI("hello")) + end) + end) +end) diff --git a/modules/transfer/spec/take/comms_spec.lua b/modules/transfer/spec/take/comms_spec.lua new file mode 100644 index 00000000000..e57be2f2db6 --- /dev/null +++ b/modules/transfer/spec/take/comms_spec.lua @@ -0,0 +1,22 @@ +local TakeComms = VFS.Include("modules/transfer/take/comms.lua") +local ModeEnums = VFS.Include("modules/context/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/modules/transfer/spec/unit/categories_spec.lua b/modules/transfer/spec/unit/categories_spec.lua new file mode 100644 index 00000000000..fc870b2853c --- /dev/null +++ b/modules/transfer/spec/unit/categories_spec.lua @@ -0,0 +1,229 @@ +local Categories = VFS.Include("modules/transfer/unit/categories.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") + +describe("unit_sharing_categories #categories", function() + describe("classifyUnitDef", function() + it("should classify commanders as Commander", function() + local def = { customParams = { iscommander = "1" }, weapons = { "gun" }, isBuilder = true } + assert.equal(TransferEnums.UnitType.Commander, Categories.classifyUnitDef(def)) + end) + + it("should classify air transports as Combat", function() + local def = { canFly = true, transportCapacity = 8, customParams = {} } + assert.equal(TransferEnums.UnitType.Combat, Categories.classifyUnitDef(def)) + end) + + it("should classify armed units as Combat", function() + local def = { weapons = { "gun" }, customParams = {} } + assert.equal(TransferEnums.UnitType.Combat, Categories.classifyUnitDef(def)) + end) + + it("should classify weapon-group units as Combat", function() + local def = { customParams = { unitgroup = "weapon" } } + assert.equal(TransferEnums.UnitType.Combat, Categories.classifyUnitDef(def)) + end) + + it("should classify mobile constructors as Constructor", function() + local def = { isBuilder = true, isFactory = false, customParams = { techlevel = "1" } } + assert.equal(TransferEnums.UnitType.Constructor, Categories.classifyUnitDef(def)) + end) + + it("should classify con turrets (assist, not factory) as Constructor", function() + local def = { canAssist = true, isFactory = false, customParams = {} } + assert.equal(TransferEnums.UnitType.Constructor, Categories.classifyUnitDef(def)) + end) + + it("should classify T2 constructors as Constructor (tech-agnostic)", function() + local def = { isBuilder = true, isFactory = false, customParams = { techlevel = "2" } } + assert.equal(TransferEnums.UnitType.Constructor, Categories.classifyUnitDef(def)) + end) + + it("should classify fast T2 engineers (corfast/armfark/armconsul) as Combat", function() + for _, name in ipairs({ "corfast", "armfark", "armconsul" }) do + local def = { name = name, isBuilder = true, isFactory = false, customParams = { techlevel = "2" } } + assert.equal(TransferEnums.UnitType.Combat, Categories.classifyUnitDef(def)) + end + end) + + it("should classify factories as Factory", function() + local def = { isFactory = true, isBuilder = true, customParams = {} } + assert.equal(TransferEnums.UnitType.Factory, Categories.classifyUnitDef(def)) + end) + + it("should classify energy buildings as Resource", function() + local def = { customParams = { unitgroup = "energy" } } + assert.equal(TransferEnums.UnitType.Resource, Categories.classifyUnitDef(def)) + end) + + it("should classify metal extractors as Resource", function() + local def = { customParams = { unitgroup = "metal" } } + assert.equal(TransferEnums.UnitType.Resource, Categories.classifyUnitDef(def)) + end) + + it("should classify util buildings as Utility", function() + local def = { customParams = { unitgroup = "util" } } + assert.equal(TransferEnums.UnitType.Utility, Categories.classifyUnitDef(def)) + end) + + it("should default to Combat for unrecognized units", function() + local def = { customParams = {} } + assert.equal(TransferEnums.UnitType.Combat, Categories.classifyUnitDef(def)) + end) + + it("should classify armed transports as Combat (weapons win)", function() + local def = { canFly = true, transportCapacity = 4, weapons = { "gun" }, customParams = {} } + assert.equal(TransferEnums.UnitType.Combat, Categories.classifyUnitDef(def)) + end) + + it("should prioritize Constructor over Resource for assist-capable energy buildings", function() + local def = { canAssist = true, isFactory = false, customParams = { unitgroup = "energy" } } + assert.equal(TransferEnums.UnitType.Constructor, Categories.classifyUnitDef(def)) + end) + end) + + describe("isTransportDef", function() + it("should return true for flying transports (any tech)", function() + assert.is_true(Categories.isTransportDef({ canFly = true, transportCapacity = 8, customParams = { techlevel = "2" } })) + end) + + it("should return false for non-flying units", function() + assert.is_false(Categories.isTransportDef({ canFly = false, transportCapacity = 8 })) + end) + + it("should return false for flying units without transport capacity", function() + assert.is_false(Categories.isTransportDef({ canFly = true, transportCapacity = 0 })) + end) + end) + + describe("isConstructorDef", function() + it("should return true for mobile builders", function() + assert.is_true(Categories.isConstructorDef({ isBuilder = true, isFactory = false })) + end) + + it("should return true for assist-capable units (con turrets)", function() + assert.is_true(Categories.isConstructorDef({ canAssist = true, isFactory = false })) + end) + + it("should return false for factories", function() + assert.is_false(Categories.isConstructorDef({ isBuilder = true, isFactory = true })) + end) + + it("should return false for non-builders", function() + assert.is_false(Categories.isConstructorDef({ customParams = { unitgroup = "weapon" } })) + end) + end) + + describe("isMobileBuilderDef", function() + it("should return true for mobile builders", function() + assert.is_true(Categories.isMobileBuilderDef({ isBuilder = true })) + end) + + it("should return false for immobile builders (nano/con turrets)", function() + assert.is_false(Categories.isMobileBuilderDef({ isBuilder = true, isImmobile = true })) + end) + + it("should return false for assist-only turrets that are not builders", function() + assert.is_false(Categories.isMobileBuilderDef({ canAssist = true })) + end) + + it("should return false for factories", function() + assert.is_false(Categories.isMobileBuilderDef({ isBuilder = true, isFactory = true })) + end) + + it("should return false for non-builders", function() + assert.is_false(Categories.isMobileBuilderDef({ customParams = { unitgroup = "weapon" } })) + end) + end) + + describe("isCombatGroupBuilderDef", function() + it("should return true for the listed fast T2 engineers", function() + for _, name in ipairs({ "corfast", "armfark", "armconsul" }) do + assert.is_true(Categories.isCombatGroupBuilderDef({ name = name, isBuilder = true })) + end + end) + + it("should return false for other builders", function() + assert.is_false(Categories.isCombatGroupBuilderDef({ name = "armck", isBuilder = true })) + end) + + it("should return false when name is absent", function() + assert.is_false(Categories.isCombatGroupBuilderDef({ isBuilder = true })) + end) + end) + + describe("isFactoryDef", function() + it("should return true for factories", function() + assert.is_true(Categories.isFactoryDef({ isFactory = true })) + end) + + it("should return false for non-factories", function() + assert.is_false(Categories.isFactoryDef({ isBuilder = true })) + end) + end) + + describe("isCombatUnitDef", function() + it("should return true for weapon-group units", function() + assert.is_true(Categories.isCombatUnitDef({ customParams = { unitgroup = "weapon" } })) + end) + + it("should return true for aa-group units", function() + assert.is_true(Categories.isCombatUnitDef({ customParams = { unitgroup = "aa" } })) + end) + + it("should return true for sub-group units", function() + assert.is_true(Categories.isCombatUnitDef({ customParams = { unitgroup = "sub" } })) + end) + + it("should return true for nuke-group units", function() + assert.is_true(Categories.isCombatUnitDef({ customParams = { unitgroup = "nuke" } })) + end) + + it("should return true for units with weapons list", function() + assert.is_true(Categories.isCombatUnitDef({ weapons = { "gun" }, customParams = {} })) + end) + + it("should return false for unarmed non-combat units", function() + assert.is_false(Categories.isCombatUnitDef({ customParams = { unitgroup = "energy" } })) + end) + + it("should return false for units with empty weapons list", function() + assert.is_false(Categories.isCombatUnitDef({ weapons = {}, customParams = {} })) + end) + end) + + describe("isResourceUnitDef", function() + it("should return true for energy buildings", function() + assert.is_true(Categories.isResourceUnitDef({ customParams = { unitgroup = "energy" } })) + end) + + it("should return true for metal buildings", function() + assert.is_true(Categories.isResourceUnitDef({ customParams = { unitgroup = "metal" } })) + end) + + it("should return false for util buildings", function() + assert.is_false(Categories.isResourceUnitDef({ customParams = { unitgroup = "util" } })) + end) + + it("should return false for combat units", function() + assert.is_false(Categories.isResourceUnitDef({ customParams = { unitgroup = "weapon" } })) + end) + end) + + describe("isUtilityUnitDef", function() + it("should return true for util buildings", function() + assert.is_true(Categories.isUtilityUnitDef({ customParams = { unitgroup = "util" } })) + end) + + it("should return false for energy buildings", function() + assert.is_false(Categories.isUtilityUnitDef({ customParams = { unitgroup = "energy" } })) + end) + + it("should return false for combat units", function() + assert.is_false(Categories.isUtilityUnitDef({ customParams = { unitgroup = "weapon" } })) + end) + + it("should return false for units without customParams", function() + assert.is_false(Categories.isUtilityUnitDef({})) + end) + end) +end) diff --git a/modules/transfer/spec/unit/factor_cache_spec.lua b/modules/transfer/spec/unit/factor_cache_spec.lua new file mode 100644 index 00000000000..1c1c7d59238 --- /dev/null +++ b/modules/transfer/spec/unit/factor_cache_spec.lua @@ -0,0 +1,87 @@ +---@type Builders +local Builders = VFS.Include("spec/builders/index.lua") +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local ContextFactoryModule = VFS.Include("modules/context/context_factory.lua") +local UnitTransfer = VFS.Include("modules/transfer/unit/synced.lua") +local UnitShared = VFS.Include("modules/transfer/unit/shared.lua") + +local ALL = ModeEnums.UnitFilterCategory.All +local NONE = ModeEnums.UnitFilterCategory.None + +-- The unit cache stores one factor record per team (sharing modes + active flag); +-- GetCachedPolicyResult reconstructs any pair from those plus live alliance/cheat gates. +describe("unit policy cache (per-team factors) #policy", function() + local sender, receiver, enemy ---@type TeamBuilder, TeamBuilder, TeamBuilder + local spring ---@type SpringSyncedBuilder + + before_each(function() + sender = Builders.Team:new():Human() + receiver = Builders.Team:new():Human() + enemy = Builders.Team:new():Human() + spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):WithTeam(enemy):WithAlliance(sender.id, receiver.id, true):WithAlliance(receiver.id, sender.id, true):WithModOption(ModeEnums.ModOptions.UnitSharingMode, ALL):WithTeamRulesParam(sender.id, "numActivePlayers", 1):WithTeamRulesParam(receiver.id, "numActivePlayers", 1):WithTeamRulesParam(enemy.id, "numActivePlayers", 1) + end) + + -- Build the mock and populate each team's unit factor. + local function populate() + local springApi = spring:Build() + local contextFactory = ContextFactoryModule.create(springApi) + for _, teamId in ipairs(springApi.GetTeamList()) do + UnitTransfer.CacheTeamFactor(springApi, teamId, contextFactory.policy(teamId, teamId)) + end + return springApi, contextFactory + end + + it("reconstructs an allied pair identically to a direct GetPolicy", function() + local springApi, contextFactory = populate() + local cached = UnitShared.GetCachedPolicyResult(sender.id, receiver.id, springApi) + local direct = UnitTransfer.GetPolicy(contextFactory.policy(sender.id, receiver.id)) + assert.equal(direct.canShare, cached.canShare) + assert.same(direct.sharingModes, cached.sharingModes) + end) + + it("allows an allied pair under a shareable mode", function() + local springApi = populate() + assert.equal(true, UnitShared.GetCachedPolicyResult(sender.id, receiver.id, springApi).canShare) + end) + + it("denies a cross-alliance pair", function() + local springApi = populate() + assert.equal(false, UnitShared.GetCachedPolicyResult(sender.id, enemy.id, springApi).canShare) + end) + + it("denies when the sharing mode is None", function() + spring:WithModOption(ModeEnums.ModOptions.UnitSharingMode, NONE) + local springApi = populate() + assert.equal(false, UnitShared.GetCachedPolicyResult(sender.id, receiver.id, springApi).canShare) + end) + + it("denies when the receiver has no active players", function() + spring:WithTeamRulesParam(receiver.id, "numActivePlayers", 0) + local springApi = populate() + assert.equal(false, UnitShared.GetCachedPolicyResult(sender.id, receiver.id, springApi).canShare) + end) + + it("cheating bypasses the inactive-receiver gate", function() + spring:WithTeamRulesParam(receiver.id, "numActivePlayers", 0) + local springApi = populate() + springApi.IsCheatingEnabled = function() + return true + end + assert.equal(true, UnitShared.GetCachedPolicyResult(sender.id, receiver.id, springApi).canShare) + end) + + it("cheating does NOT bypass the alliance gate (units, unlike resources)", function() + local springApi = populate() + springApi.IsCheatingEnabled = function() + return true + end + assert.equal(false, UnitShared.GetCachedPolicyResult(sender.id, enemy.id, springApi).canShare) + end) + + it("falls back to the global mode + alliance when factors are absent", function() + local springApi = spring:Build() + -- no populate(): allied pair under a shareable global mode is still allowed + assert.equal(true, UnitShared.GetCachedPolicyResult(sender.id, receiver.id, springApi).canShare) + assert.equal(false, UnitShared.GetCachedPolicyResult(sender.id, enemy.id, springApi).canShare) + end) +end) diff --git a/modules/transfer/spec/unit/transfer_spec.lua b/modules/transfer/spec/unit/transfer_spec.lua new file mode 100644 index 00000000000..84d5e8ac291 --- /dev/null +++ b/modules/transfer/spec/unit/transfer_spec.lua @@ -0,0 +1,782 @@ +---@type Builders +local Builders = VFS.Include("spec/builders/index.lua") +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local UnitTransfer = VFS.Include("modules/transfer/unit/synced.lua") +local ContextFactoryModule = VFS.Include("modules/context/context_factory.lua") + +local Units = { + AdvancedConstructor = "coracv", + Pawn = "armpw", + Fusion = "armfus", + Constructor = "corcv", +} + +---@class UnitSharingTestConfig +---@field mode string The sharing mode to test +---@field canShareUnits boolean Expected canShareUnits result +---@field testUnits table Map of unit names to expected outcomes + +---@type table +local testConfigs = { + [ModeEnums.UnitFilterCategory.None] = { + mode = ModeEnums.UnitFilterCategory.None, + canShareUnits = false, + testUnits = { + [Units.AdvancedConstructor] = false, + [Units.Fusion] = false, + }, + }, + [ModeEnums.UnitFilterCategory.All] = { + mode = ModeEnums.UnitFilterCategory.All, + canShareUnits = true, + testUnits = { + [Units.AdvancedConstructor] = true, + [Units.Fusion] = true, + }, + }, + [ModeEnums.UnitFilterCategory.Combat] = { + mode = ModeEnums.UnitFilterCategory.Combat, + canShareUnits = true, + testUnits = { + [Units.Pawn] = true, + [Units.Constructor] = false, + [Units.AdvancedConstructor] = false, + [Units.Fusion] = false, + }, + }, + [ModeEnums.UnitFilterCategory.Constructors] = { + mode = ModeEnums.UnitFilterCategory.Constructors, + canShareUnits = true, + testUnits = { + [Units.Constructor] = true, + [Units.AdvancedConstructor] = true, + [Units.Fusion] = false, + [Units.Pawn] = false, + }, + }, + [ModeEnums.UnitFilterCategory.Buildings] = { + mode = ModeEnums.UnitFilterCategory.Buildings, + canShareUnits = true, + testUnits = { + [Units.Fusion] = true, + [Units.Constructor] = false, + [Units.AdvancedConstructor] = false, + [Units.Pawn] = false, + }, + }, + [ModeEnums.UnitFilterCategory.Resource] = { + mode = ModeEnums.UnitFilterCategory.Resource, + canShareUnits = true, + testUnits = { + [Units.Fusion] = true, + [Units.Constructor] = false, + [Units.Pawn] = false, + }, + }, + [ModeEnums.UnitFilterCategory.NonCombat] = { + mode = ModeEnums.UnitFilterCategory.NonCombat, + canShareUnits = true, + testUnits = { + [Units.Constructor] = true, + [Units.AdvancedConstructor] = true, + [Units.Fusion] = true, + [Units.Pawn] = false, + }, + }, +} + +describe(ModeEnums.ModOptions.UnitSharingMode .. " #policy", function() + local sender = Builders.Team:new():Human() + local receiver = Builders.Team:new():Human() + + local spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):WithAlliance(sender.id, receiver.id, true) + + for modeKey, config in pairs(testConfigs) do + describe("WHEN unit sharing mode is set to " .. config.mode, function() + spring:WithModOption(ModeEnums.ModOptions.UnitSharingMode, config.mode) + local result ---@type UnitPolicyResult + local unitIds = {} ---@type table + local api ---@type SpringSyncedMock + + before_each(function() + unitIds = {} + sender.units = {} + for unitDefName, _ in pairs(config.testUnits) do + sender:WithUnit(unitDefName, function(id) + unitIds[unitDefName] = id + end) + end + spring:WithRealUnitDefs() + api = spring:Build() + local defsByKey = {} + local defs = api.GetUnitDefs() + for key, def in pairs(defs or {}) do + defsByKey[key] = def + if def.id then + defsByKey[def.id] = def + end + if def.name then + defsByKey[def.name] = def + end + end + ---@diagnostic disable-next-line: global-in-non-module + _G.UnitDefs = defsByKey + local ctx = ContextFactoryModule.create(api).policy(sender.id, receiver.id) + result = UnitTransfer.GetPolicy(ctx) + end) + + after_each(function() + ---@diagnostic disable-next-line: global-in-non-module + _G.UnitDefs = nil + end) + + it("should have correct sharing permissions", function() + assert.equal(config.canShareUnits, result.canShare) + end) + + for unitDefName, shouldAllow in pairs(config.testUnits) do + it("should " .. (shouldAllow and "allow" or "not allow") .. " validating transfer of " .. unitDefName, function() + local unitId = unitIds[unitDefName] + assert.is_not_nil(unitId) + local validation = UnitTransfer.ValidateUnits(result, { unitId }, api, _G.UnitDefs) + if not config.canShareUnits then + -- Disabled mode short-circuits validation + assert.equal(0, validation.validUnitCount) + assert.equal(0, validation.invalidUnitCount) + else + if shouldAllow then + assert.equal(1, validation.validUnitCount) + assert.equal(0, validation.invalidUnitCount) + else + assert.equal(0, validation.validUnitCount) + assert.equal(1, validation.invalidUnitCount) + end + end + end) + end + end) + end +end) + +describe("UnitTransfer #action", function() + local sender = Builders.Team:new():Human() + local receiver = Builders.Team:new():Human() + local spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):Build() + + describe("when unit sharing is allowed", function() + local unitIds ---@type integer[] + local result + local spring ---@type SpringSyncedMock + + before_each(function() + unitIds = {} + sender:WithUnit("armpw", function(unitId) + table.insert(unitIds, unitId) + end) + sender:WithUnit("armck", function(unitId) + table.insert(unitIds, unitId) + end) + + -- Rebuild spring repo after adding units + spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):Build() + end) + + it("should successfully transfer all units when sharing is allowed", function() + ---@type UnitTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.UnitTransfer, + unitIds = unitIds, + given = false, + springRepo = spring, + areAlliedTeams = true, + isCheatingEnabled = false, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + validationResult = { + status = TransferEnums.UnitValidationOutcome.Success, + validUnitIds = unitIds, + validUnitCount = #unitIds, + validUnitNames = {}, + invalidUnitIds = {}, + invalidUnitCount = 0, + invalidUnitNames = {}, + }, + policyResult = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + local transferSpy = spy.on(spring, "TransferUnit") + result = UnitTransfer.UnitTransfer(ctx) + + assert.equal(TransferEnums.UnitValidationOutcome.Success, result.outcome) + assert.equal(#unitIds, result.validationResult.validUnitCount) + assert.equal(0, result.validationResult.invalidUnitCount) + assert.spy(transferSpy).was.called(#unitIds) + assert.spy(transferSpy).was.called_with(unitIds[1], receiver.id, false) + end) + + it("should handle mixed success/failure scenarios", function() + local mixedUnitIds = { assert(unitIds[1]), 9999, assert(unitIds[2]) } + + ---@type UnitTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.UnitTransfer, + unitIds = mixedUnitIds, + given = false, + springRepo = spring, + areAlliedTeams = true, + isCheatingEnabled = false, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + validationResult = { + status = TransferEnums.UnitValidationOutcome.PartialSuccess, + validUnitIds = { mixedUnitIds[1], mixedUnitIds[3] }, + validUnitCount = 2, + validUnitNames = {}, + invalidUnitIds = { mixedUnitIds[2] }, + invalidUnitCount = 1, + invalidUnitNames = {}, + }, + policyResult = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + result = UnitTransfer.UnitTransfer(ctx) + + assert.equal(TransferEnums.UnitValidationOutcome.PartialSuccess, result.outcome) + assert.equal(2, result.validationResult.validUnitCount) + assert.equal(1, result.validationResult.invalidUnitCount) + assert.equal(9999, result.validationResult.invalidUnitIds[1]) + end) + + it("should set given parameter correctly", function() + ---@type UnitTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.UnitTransfer, + unitIds = { unitIds[1] }, + given = true, + springRepo = spring, + areAlliedTeams = true, + isCheatingEnabled = false, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + validationResult = { + status = TransferEnums.UnitValidationOutcome.Success, + validUnitIds = { unitIds[1] }, + validUnitCount = 1, + validUnitNames = {}, + invalidUnitIds = {}, + invalidUnitCount = 0, + invalidUnitNames = {}, + }, + policyResult = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + result = UnitTransfer.UnitTransfer(ctx) + + assert.equal(TransferEnums.UnitValidationOutcome.Success, result.outcome) + assert.equal(1, result.validationResult.validUnitCount) + assert.equal(0, result.validationResult.invalidUnitCount) + end) + end) + + describe("when unit sharing is not allowed", function() + local unitIds ---@type integer[] + local result + local spring ---@type SpringSyncedMock + + before_each(function() + unitIds = {} + sender:WithUnit("armpw", function(unitId) + table.insert(unitIds, unitId) + end) + sender:WithUnit("armck", function(unitId) + table.insert(unitIds, unitId) + end) + + -- Rebuild spring repo after adding units + spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):Build() + end) + + it("should fail to transfer any units when sharing is disabled", function() + ---@type UnitTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.UnitTransfer, + unitIds = unitIds, + given = false, + springRepo = spring, + areAlliedTeams = true, + isCheatingEnabled = false, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + validationResult = { + status = TransferEnums.UnitValidationOutcome.Success, + validUnitIds = unitIds, + validUnitCount = #unitIds, + validUnitNames = {}, + invalidUnitIds = {}, + invalidUnitCount = 0, + invalidUnitNames = {}, + }, + policyResult = { + canShare = false, + sharingModes = { ModeEnums.UnitFilterCategory.None }, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + result = UnitTransfer.UnitTransfer(ctx) + + assert.equal(TransferEnums.UnitValidationOutcome.Success, result.validationResult.status) + assert.equal(TransferEnums.UnitValidationOutcome.Failure, result.outcome) + assert.equal(#unitIds, result.validationResult.validUnitCount) + end) + + it("should include policy result in response", function() + local policyResult = { + canShare = false, + sharingModes = { ModeEnums.UnitFilterCategory.None }, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + } + + ---@type UnitTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.UnitTransfer, + unitIds = unitIds, + given = false, + springRepo = spring, + areAlliedTeams = true, + isCheatingEnabled = false, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + validationResult = { + status = TransferEnums.UnitValidationOutcome.Success, + validUnitIds = unitIds, + validUnitCount = #unitIds, + validUnitNames = {}, + invalidUnitIds = {}, + invalidUnitCount = 0, + invalidUnitNames = {}, + }, + policyResult = policyResult, + } + + result = UnitTransfer.UnitTransfer(ctx) + + assert.equal(policyResult, result.policyResult) + end) + end) + + describe("edge cases", function() + it("should handle empty unit list", function() + ---@type UnitTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.UnitTransfer, + unitIds = {}, + springRepo = spring, + areAlliedTeams = true, + isCheatingEnabled = false, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + validationResult = { + status = TransferEnums.UnitValidationOutcome.Failure, + validUnitIds = {}, + validUnitCount = 0, + validUnitNames = {}, + invalidUnitIds = {}, + invalidUnitCount = 0, + invalidUnitNames = {}, + }, + given = false, + policyResult = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + local result = UnitTransfer.UnitTransfer(ctx) + + assert.equal(TransferEnums.UnitValidationOutcome.Failure, result.outcome) + assert.equal(0, result.validationResult.validUnitCount) + assert.equal(0, result.validationResult.invalidUnitCount) + end) + + it("should handle invalid unit IDs gracefully", function() + ---@type UnitTransferContext + local ctx = { + senderTeamId = sender.id, + receiverTeamId = receiver.id, + policyType = TransferEnums.PolicyType.UnitTransfer, + unitIds = { -1, 0, 9999 }, + springRepo = spring, + areAlliedTeams = true, + isCheatingEnabled = false, + ext = {}, + sender = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + receiver = { + metal = { resourceType = "metal", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + energy = { resourceType = "energy", excess = 0, current = 1000, storage = 1000, pull = 0, income = 0, expense = 0, shareSlider = 0, sent = 0, received = 0 }, + }, + validationResult = { + status = TransferEnums.UnitValidationOutcome.Failure, + validUnitIds = {}, + validUnitCount = 0, + validUnitNames = {}, + invalidUnitIds = { -1, 0, 9999 }, + invalidUnitCount = 3, + invalidUnitNames = {}, + }, + given = false, + policyResult = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + senderTeamId = sender.id, + receiverTeamId = receiver.id, + }, + } + + local result = UnitTransfer.UnitTransfer(ctx) + + assert.equal(TransferEnums.UnitValidationOutcome.Failure, result.outcome) + assert.equal(0, result.validationResult.validUnitCount) + assert.equal(3, result.validationResult.invalidUnitCount) + end) + end) +end) + +describe("Easy Tax stun mechanics #stun", function() + local easyTaxMode = VFS.Include("modules/transfer/modes/easy_tax.lua") + + local function modeModOpts(modeConfig) + local opts = {} + for key, entry in pairs(modeConfig.modOptions) do + local value = entry.value + if type(value) == "boolean" then + opts[key] = value and "1" or "0" + else + opts[key] = tostring(value) + end + end + return opts + end + + local sender = Builders.Team:new():Human() + local receiver = Builders.Team:new():Human() + local spring = Builders.Spring.new():WithTeam(sender):WithTeam(receiver):WithAlliance(sender.id, receiver.id, true):WithTeamRulesParam(receiver.id, "numActivePlayers", 1):WithTeamRulesParam(sender.id, "numActivePlayers", 1) + + local mockDefs = { + [1] = { id = 1, name = "mockfusion", customParams = { unitgroup = "energy" } }, + [2] = { id = 2, name = "mockcon", canAssist = true, buildOptions = {}, customParams = { techlevel = "1" } }, + [3] = { id = 3, name = "mockpawn", customParams = { unitgroup = "weapon" }, weapons = { "gun" } }, + [4] = { id = 4, name = "mockbuilder", isBuilder = true, customParams = { techlevel = "1" } }, + } + + describe("GetPolicy", function() + it("should expose stunSeconds, stunCategory and buildDelaySeconds from Easy Tax config", function() + local api = spring:Build() + api.GetModOptions = function() + return modeModOpts(easyTaxMode) + end + + local ctx = ContextFactoryModule.create(api).policy(sender.id, receiver.id) + local policy = UnitTransfer.GetPolicy(ctx) + + assert.equal(30, policy.stunSeconds) + assert.equal(ModeEnums.UnitFilterCategory.Resource, policy.stunCategory) + assert.equal(30, policy.buildDelaySeconds) + end) + + it("should default stunSeconds and buildDelaySeconds to 0 when not configured", function() + local api = spring:Build() + api.GetModOptions = function() + return { unit_sharing_mode = "all" } + end + + local ctx = ContextFactoryModule.create(api).policy(sender.id, receiver.id) + local policy = UnitTransfer.GetPolicy(ctx) + + assert.equal(0, policy.stunSeconds) + assert.equal(0, policy.buildDelaySeconds) + end) + end) + + describe("ValidateUnits nanoframe blocking", function() + it("should block nanoframes of stun-category units (resource building)", function() + local nanoframeId = 901 + sender.units = {} + sender.units[nanoframeId] = { unitDefId = 1, beingBuilt = true, buildProgress = 0.5 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { nanoframeId }, api, mockDefs) + assert.equal(0, result.validUnitCount) + assert.equal(1, result.invalidUnitCount) + end) + + it("should allow nanoframes of production units when stun category is resource", function() + local nanoframeId = 902 + sender.units = {} + sender.units[nanoframeId] = { unitDefId = 2, beingBuilt = true, buildProgress = 0.3 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { nanoframeId }, api, mockDefs) + assert.equal(1, result.validUnitCount) + assert.equal(0, result.invalidUnitCount) + end) + + it("should allow completed stun-category units", function() + local completedId = 903 + sender.units = {} + sender.units[completedId] = { unitDefId = 1, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { completedId }, api, mockDefs) + assert.equal(1, result.validUnitCount) + assert.equal(0, result.invalidUnitCount) + end) + + it("should allow combat nanoframes (not in EconomicPlusBuildings stun category)", function() + local combatNanoId = 904 + sender.units = {} + sender.units[combatNanoId] = { unitDefId = 3, beingBuilt = true, buildProgress = 0.3 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { combatNanoId }, api, mockDefs) + assert.equal(1, result.validUnitCount) + assert.equal(0, result.invalidUnitCount) + end) + + it("should allow all nanoframes when stunSeconds is 0", function() + local nanoframeId = 905 + sender.units = {} + sender.units[nanoframeId] = { unitDefId = 1, beingBuilt = true, buildProgress = 0.5 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 0, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { nanoframeId }, api, mockDefs) + assert.equal(1, result.validUnitCount) + assert.equal(0, result.invalidUnitCount) + end) + + it("should handle mixed nanoframes and completed units", function() + local nanoId = 906 + local completedId = 907 + sender.units = {} + sender.units[nanoId] = { unitDefId = 1, beingBuilt = true, buildProgress = 0.5 } + sender.units[completedId] = { unitDefId = 1, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { nanoId, completedId }, api, mockDefs) + assert.equal(1, result.validUnitCount) + assert.equal(1, result.invalidUnitCount) + assert.equal(TransferEnums.UnitValidationOutcome.PartialSuccess, result.status) + end) + + it("should block nanoframes with Combat stun category", function() + local combatNanoId = 908 + sender.units = {} + sender.units[combatNanoId] = { unitDefId = 3, beingBuilt = true, buildProgress = 0.4 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 15, + stunCategory = ModeEnums.UnitFilterCategory.Combat, + } + + local result = UnitTransfer.ValidateUnits(policy, { combatNanoId }, api, mockDefs) + assert.equal(0, result.validUnitCount) + assert.equal(1, result.invalidUnitCount) + end) + end) + + describe("ValidateUnits build-delay tally", function() + it("counts mobile builders among the valid units", function() + local builderId, fusionId = 910, 911 + sender.units = {} + sender.units[builderId] = { unitDefId = 4, beingBuilt = false, buildProgress = 1.0 } + sender.units[fusionId] = { unitDefId = 1, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 0, + } + + local result = UnitTransfer.ValidateUnits(policy, { builderId, fusionId }, api, mockDefs) + assert.equal(2, result.validUnitCount) + assert.equal(1, result.buildDelayedUnitCount) + end) + + it("counts zero when the selection has no mobile builders", function() + local fusionId = 912 + sender.units = {} + sender.units[fusionId] = { unitDefId = 1, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 0, + } + + local result = UnitTransfer.ValidateUnits(policy, { fusionId }, api, mockDefs) + assert.equal(0, result.buildDelayedUnitCount) + end) + end) + + describe("ValidateUnits stun tally", function() + it("counts stun-category units among the valid units", function() + local fusionId, builderId = 920, 921 + sender.units = {} + sender.units[fusionId] = { unitDefId = 1, beingBuilt = false, buildProgress = 1.0 } + sender.units[builderId] = { unitDefId = 4, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { fusionId, builderId }, api, mockDefs) + assert.equal(2, result.validUnitCount) + assert.equal(1, result.stunnedUnitCount) + end) + + it("counts zero when the selection has no stun-category units", function() + local builderId = 922 + sender.units = {} + sender.units[builderId] = { unitDefId = 4, beingBuilt = false, buildProgress = 1.0 } + local api = spring:Build() + + local policy = { + canShare = true, + sharingModes = { ModeEnums.UnitFilterCategory.All }, + stunSeconds = 30, + stunCategory = ModeEnums.UnitFilterCategory.Resource, + } + + local result = UnitTransfer.ValidateUnits(policy, { builderId }, api, mockDefs) + assert.equal(0, result.stunnedUnitCount) + end) + end) +end) diff --git a/modules/transfer/take/comms.lua b/modules/transfer/take/comms.lua new file mode 100644 index 00000000000..bc00663392e --- /dev/null +++ b/modules/transfer/take/comms.lua @@ -0,0 +1,79 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") + +local Comms = {} + +local categoryDisplayNames = { + [ModeEnums.UnitCategory.Combat] = "combat", + [ModeEnums.UnitCategory.Buildings] = "buildings", + [ModeEnums.UnitCategory.Constructors] = "constructors", + [ModeEnums.UnitCategory.Resource] = "resource", + [ModeEnums.UnitCategory.NonCombat] = "non-combat", + [ModeEnums.UnitFilterCategory.All] = "all", +} + +function Comms.CategoryDisplayName(category) + return categoryDisplayNames[category] or category +end + +---@class TakePolicy +---@field mode string TakeMode enum value +---@field delaySeconds number +---@field delayCategory string UnitCategory enum value + +---Resolve the global take policy; declared as a named stage in +---policies/take.lua, so the mode grammar and this agree by construction. +---@param modOptions table +---@return TakePolicy +function Comms.GetPolicy(modOptions) + local ModuleHandler = VFS.Include("modules/module_handler.lua") + return ModuleHandler.Evaluate(ModuleHandler.LoadPolicies("transfer").take, { modOptions = modOptions }) +end + +---@class TakeResult +---@field mode string TakeMode enum value +---@field takerName string Name of the player who issued /take +---@field sourceName string Name of the player/team being taken from +---@field transferred number Units transferred this pass +---@field stunned number Units stunned (StunDelay only) +---@field delayed number Units held back (TakeDelay first pass only) +---@field total number Total units on the source team before take +---@field category string Category enum value +---@field delaySeconds number +---@field remainingSeconds number? Seconds left for pending TakeDelay +---@field isSecondPass boolean? True when completing a TakeDelay + +---@param result TakeResult +---@return string +function Comms.FormatMessage(result) + local taker = result.takerName or "Unknown" + local source = result.sourceName or "Unknown" + + if result.mode == ModeEnums.TakeMode.Disabled then + return "Take is disabled" + end + + if result.mode == ModeEnums.TakeMode.Enabled then + return string.format("%s took %d units and resources from %s", taker, result.transferred, source) + end + + if result.mode == ModeEnums.TakeMode.StunDelay then + if result.stunned > 0 then + return string.format("%s took %d units and resources from %s; %d %s units stunned for %ds", taker, result.transferred, source, result.stunned, Comms.CategoryDisplayName(result.category), result.delaySeconds) + end + return string.format("%s took %d units and resources from %s", taker, result.transferred, source) + end + + if result.mode == ModeEnums.TakeMode.TakeDelay then + if result.isSecondPass then + return string.format("%s took remaining %d %s units and resources from %s", taker, result.transferred, Comms.CategoryDisplayName(result.category), source) + end + if result.remainingSeconds then + return string.format("%s: %ds remaining before %s's %s units can be taken", taker, result.remainingSeconds, source, Comms.CategoryDisplayName(result.category)) + end + return string.format("%s took %d/%d units from %s; %d %s units available for /take in %ds", taker, result.transferred, result.total, source, result.delayed, Comms.CategoryDisplayName(result.category), result.delaySeconds) + end + + return "" +end + +return Comms diff --git a/modules/transfer/types/actions.lua b/modules/transfer/types/actions.lua new file mode 100644 index 00000000000..35efbd50503 --- /dev/null +++ b/modules/transfer/types/actions.lua @@ -0,0 +1,51 @@ +---@meta actions + +--- Transfer's actions, declared once for both grammars. A mode names one to +--- grant it, a mission calls one to perform it, and this is the declaration +--- both read — an action cannot mean one thing to a mode file and another to +--- a trigger file, because there is only the one entry. +--- +--- An action is callable where it can be performed (@overload) and carries +--- variants where a grant can be narrowed (.AtT2, .Constructors). Either +--- facet may be absent: Give cannot be granted, Assist cannot be performed. + +--- Narrowing a grant: the same action, said of one unit category or one tech +--- tier. Not callable — a mission performs the action, not a slice of it. +---@class TransferGrant +---@field domain string +---@field category string|nil +---@field tier integer|nil + +--- Units: grantable, narrowable, and performable. +---@class TransferUnits : TransferGrant +---@field AtT2 TransferGrant +---@field AtT3 TransferGrant +---@field Constructors TransferGrant +---@field Resource TransferGrant +---@overload fun(group: MissionUnitGroup, team: MissionTeam): MissionEffect + +--- Resources: grantable and narrowable; nothing performs it from a trigger. +---@class TransferResources : TransferGrant +---@field Metal TransferGrant +---@field Energy TransferGrant +---@field AtT2 TransferGrant +---@field AtT3 TransferGrant + +--- Fiat: performable, and deliberately not grantable — a mode has no say, so +--- there is no domain for a grant to be written against. +---@class TransferGive +---@overload fun(group: MissionUnitGroup, team: MissionTeam): MissionEffect + +---@class TransferActions +---@field Units TransferUnits +---@field Resources TransferResources +---@field Give TransferGive + +---@type TransferActions +Transfer = {} + +---@type TransferGrant +Take = {} + +---@type TransferGrant +Tech = {} diff --git a/modules/transfer/types/mode_policy.lua b/modules/transfer/types/mode_policy.lua new file mode 100644 index 00000000000..95141a89fb7 --- /dev/null +++ b/modules/transfer/types/mode_policy.lua @@ -0,0 +1,31 @@ +---@meta policy mode + +--- The transfer mode-preset surface: what modules/transfer/modes/*.lua files +--- author against (via mode_dsl.lua over modules/mode_builder.lua). + + +--- The Mode chain (transfer vocabulary). Every verb returns the chain; the +--- chain IS the ModeConfig the preset file returns. Modifiers (Hidden, +--- Unlocked, Locked) apply to the most recent policy. +---@class TransferModeChain +---@field Desc fun(desc: string): TransferModeChain +---@field Ranked fun(enabled: boolean?): TransferModeChain permission is a flag; Ranked(false) pins ranked_game off, lockable like any policy +---@field RetainValues fun(): TransferModeChain +---@field Hidden fun(): TransferModeChain +---@field Unlocked fun(): TransferModeChain +---@field Locked fun(): TransferModeChain +---@field Allow fun(noun: TransferGrant): TransferModeChain +---@field Deny fun(noun: TransferGrant): TransferModeChain +---@field Tax fun(noun: TransferGrant, rate: number): TransferModeChain +---@field Stun fun(noun: TransferGrant, seconds: number?): TransferModeChain +---@field Defer fun(noun: TransferGrant): TransferModeChain +---@field Delay fun(noun: TransferGrant, seconds: number): TransferModeChain +---@field Gate fun(noun: TransferGrant, t2: number, t3: number): TransferModeChain +---@field Open fun(noun: TransferGrant, t2: number, t3: number): TransferModeChain + +---Start a mode chain; the key is the name's snake_case. The category is +---not a parameter: the grammar binds every chain from this module to +---"transfer". +---@param name string +---@return TransferModeChain +function Mode(name) end diff --git a/modules/transfer/types/sharing.lua b/modules/transfer/types/sharing.lua new file mode 100644 index 00000000000..f77ace07311 --- /dev/null +++ b/modules/transfer/types/sharing.lua @@ -0,0 +1,169 @@ +-- Team transfer type definitions: minimal types for IntelliSense and the linter. + +---@alias PolicyType "metal_transfer" | "energy_transfer" | "unit_transfer" + +--- Per-resource snapshot from the BAR-owned GG.GetTeamResourceData (Spring.GetTeamResources plus Lua-owned sent/received). +---@class ResourceData +---@field resourceType ResourceName +---@field current number Engine-owned snapshot; read-only to Lua +---@field storage number Engine-owned snapshot; read-only to Lua +---@field pull number? Engine-owned snapshot; read-only to Lua; absent on solver snapshots +---@field income number? Engine-owned snapshot; read-only to Lua; absent on solver snapshots +---@field expense number? Engine-owned snapshot; read-only to Lua; absent on solver snapshots +---@field shareSlider number Engine stores, Lua interprets +---@field sent number +---@field received number +---@field excess number Overflow pool to redistribute (solver input); last tick's wasted amount in GetTeamResourceData + +---@class TeamResourceData +---@field allyTeam integer +---@field isDead boolean +---@field metal ResourceData? absent when the snapshot skipped the resource (solver guards on it) +---@field energy ResourceData? absent when the snapshot skipped the resource (solver guards on it) +---@field [ResourceName] ResourceData? dynamic lookup form of the metal/energy fields + +---@class PolicyResult +---@field senderTeamId integer +---@field receiverTeamId integer + +-- Unit Transfer Action +---@class UnitPolicyResult : PolicyResult +---@field canShare boolean +---@field sharingModes string[] +---@field stunSeconds number? +---@field stunCategory string? +---@field buildDelaySeconds number? +---@field techBlocking? TechBlockingContext + +---@class UnitTransferContext : PolicyActionContext +---@field unitIds integer[] +---@field given boolean? +---@field validationResult UnitValidationResult +---@field policyResult UnitPolicyResult + +---@class UnitValidationResult +---@field status string TransferEnums.UnitValidationOutcome +---@field buildDelayedUnitCount integer? Valid units that will receive the constructor build delay +---@field stunnedUnitCount integer? Valid units that will be stunned (stun category) +---@field invalidUnitCount integer +---@field invalidUnitIds integer[] +---@field invalidUnitNames string[] +---@field validUnitCount integer +---@field validUnitIds integer[] +---@field validUnitNames string[] + +---@class UnitTransferResult +---@field success boolean +---@field outcome string TransferEnums.UnitValidationOutcome +---@field senderTeamId integer +---@field receiverTeamId integer +---@field validationResult UnitValidationResult +---@field policyResult UnitPolicyResult + +---@class GameUnitTransferController +---@field AllowUnitTransfer fun(unitID: integer, unitDefID: integer, fromTeamID: integer, toTeamID: integer, capture: boolean): boolean +---@field TeamShare fun(srcTeamID: integer, dstTeamID: integer) + +-- Resource Transfer Action + +---@class ResourcePolicyResult : PolicyResult +---@field canShare boolean +---@field amountSendable number +---@field amountReceivable number +---@field taxedPortion number +---@field taxRate number +---@field resourceType ResourceName +---@field techBlocking? TechBlockingContext + +---@class ResourceTransferContext : PolicyActionContext +---@field resourceType ResourceName +---@field desiredAmount number +---@field policyResult ResourcePolicyResult + +---@class ResourceTransferResult +---@field success boolean +---@field sent number +---@field received number +---@field senderTeamId integer +---@field receiverTeamId integer +---@field policyResult ResourcePolicyResult? absent when the transfer was denied before a policy resolved + +--- Policy Context + +---@class TeamResources +---@field metal ResourceData +---@field energy ResourceData + +---@class TechUnlockInfo +---@field unlockLevel number Tech level at which this domain next changes +---@field unlockThreshold number Keystones needed to reach that level +---@field unlockValue any What activates (mode string for units, rate number for tax) + +---@class TechBlockingContext +---@field level number Current latched tech level (1/2/3) +---@field points number Alive Keystone count +---@field t2Threshold number Computed T2 threshold (per-player * team size) +---@field t3Threshold number Computed T3 threshold +---@field nextLevel number Next tech level to reach (2 or 3, or 3 if maxed) +---@field nextThreshold number Keystones needed for next level +---@field unitTransfer? TechUnlockInfo Next unit sharing mode change +---@field metalTransfer? TechUnlockInfo Next metal tax rate change +---@field energyTransfer? TechUnlockInfo Next energy tax rate change + +---@class PolicyContextExtensions +---@field techBlocking? TechBlockingContext + +---@class PolicyContext +---@field senderTeamId integer +---@field receiverTeamId integer +---@field sender TeamResources +---@field receiver TeamResources +---@field springRepo Spring +---@field areAlliedTeams boolean +---@field isCheatingEnabled boolean +---@field ext PolicyContextExtensions +---@field unitSharingModes? string[] Effective sharing modes (set by enricher, e.g. tech blocking) +---@field taxRate? number Effective tax rate (set by enricher, e.g. tech blocking) + +---@class PolicyActionContext : PolicyContext +---@field policyType string TransferEnums.PolicyType + +---@class EconomyShareMember +---@field teamId integer +---@field allyTeam integer +---@field resourceType ResourceName +---@field resource ResourceData +---@field current number effective current = resource.current + resource.excess +---@field storage number +---@field shareCursor number +---@field target number? + +--- Solver output; the controller applies pools, excess, and sent/received itself. +---@class EconomyTeamResult +---@field teamId integer +---@field resourceType ResourceName +---@field delta number Net change vs the snapshot (informational; conservation/tests) +---@field sent number +---@field received number +---@field excess number Wasted overflow this tick + +---@class EconomyFlowLedger +---@field received number +---@field sent number +---@field taxed number +---@field wasted number Overflow lost to full storages this window +---@field snapshot number Pool level when the window opened (delta base for publishing); 0 on per-solve ledgers + +--- Per-member flow inside one waterfill solve (solver-internal). +---@class EconomyShareDelta +---@field gross number Signed pool movement (negative = send) +---@field net number Post-tax amount credited to the receiver +---@field taxed number Amount withheld by tax + +---@alias EconomyFlowSummary table> + +---@class ResourceShareParams +---@field senderTeamID integer +---@field targetTeamID integer +---@field resourceType string +---@field amount number diff --git a/modules/transfer/unit/categories.lua b/modules/transfer/unit/categories.lua new file mode 100644 index 00000000000..de7ef44e7dd --- /dev/null +++ b/modules/transfer/unit/categories.lua @@ -0,0 +1,126 @@ +local TransferEnums = VFS.Include("modules/context/enums.lua") + +local sharing = {} + +---fast/mobile T2 engineers that share with Combat rather than Constructor +local combatGroupBuilders = { + corfast = true, -- Twitcher + armfark = true, -- Butler + armconsul = true, -- Consul +} + +---Classify a unit definition by type +---Each unit def resolves to exactly 1 category +---@param unitDef table Unit definition from UnitDefs +---@return string unitType One of TransferEnums.UnitType values +function sharing.classifyUnitDef(unitDef) + if sharing.isCommanderDef(unitDef) then + return TransferEnums.UnitType.Commander + end + + -- transports and fast T2 engineers fold into Combat, ahead of the constructor check + if sharing.isCombatUnitDef(unitDef) or sharing.isTransportDef(unitDef) or sharing.isCombatGroupBuilderDef(unitDef) then + return TransferEnums.UnitType.Combat + end + + if sharing.isConstructorDef(unitDef) then + return TransferEnums.UnitType.Constructor + end + + if sharing.isFactoryDef(unitDef) then + return TransferEnums.UnitType.Factory + end + + if sharing.isResourceUnitDef(unitDef) then + return TransferEnums.UnitType.Resource + end + + if sharing.isUtilityUnitDef(unitDef) then + return TransferEnums.UnitType.Utility + end + + return TransferEnums.UnitType.Combat +end + +---@param unitDef table +---@return boolean +function sharing.isCommanderDef(unitDef) + return unitDef.customParams and unitDef.customParams.iscommander ~= nil +end + +---Air transports (any tech). Folded into Combat for sharing. +---@param unitDef table +---@return boolean +function sharing.isTransportDef(unitDef) + return unitDef.canFly == true and unitDef.transportCapacity ~= nil and unitDef.transportCapacity > 0 +end + +---Fast/mobile T2 engineers explicitly grouped with Combat for sharing. +---@param unitDef table +---@return boolean +function sharing.isCombatGroupBuilderDef(unitDef) + return unitDef.name ~= nil and combatGroupBuilders[unitDef.name] == true +end + +---Mobile constructors and nano/con turrets (any tech). Factories are excluded. +---@param unitDef table +---@return boolean +function sharing.isConstructorDef(unitDef) + if unitDef.isFactory then + return false + end + return unitDef.isBuilder == true or unitDef.canAssist == true +end + +---mobile builders that receive the constructor build delay when shared (excludes immobile nano/con turrets) +---MUST match the affected set in game_unit_transfer_controller so the tooltip prediction matches reality +---@param unitDef table +---@return boolean +function sharing.isMobileBuilderDef(unitDef) + return unitDef ~= nil and unitDef.isBuilder == true and not unitDef.isImmobile and not unitDef.isFactory +end + +---Unit-producing factories. +---@param unitDef table +---@return boolean +function sharing.isFactoryDef(unitDef) + return unitDef.isFactory == true +end + +---@param unitDef table Unit definition from UnitDefs +---@return boolean isCombat True if the unit is combat-focused +function sharing.isCombatUnitDef(unitDef) + if unitDef.customParams and (unitDef.customParams.unitgroup == "weapon" or unitDef.customParams.unitgroup == "aa" or unitDef.customParams.unitgroup == "sub" or unitDef.customParams.unitgroup == "weaponaa" or unitDef.customParams.unitgroup == "weaponsub" or unitDef.customParams.unitgroup == "emp" or unitDef.customParams.unitgroup == "nuke" or unitDef.customParams.unitgroup == "antinuke" or unitDef.customParams.unitgroup == "explo") then + return true + end + + if unitDef.weapons and #unitDef.weapons > 0 then + return true + end + + return false +end + +---Metal extractors and energy producers +---@param unitDef table Unit definition from UnitDefs +---@return boolean +function sharing.isResourceUnitDef(unitDef) + if unitDef.customParams and (unitDef.customParams.unitgroup == TransferEnums.ResourceType.ENERGY or unitDef.customParams.unitgroup == TransferEnums.ResourceType.METAL) then + return true + end + + return false +end + +---Radar, storage, and other support buildings +---@param unitDef table Unit definition from UnitDefs +---@return boolean +function sharing.isUtilityUnitDef(unitDef) + if unitDef.customParams and unitDef.customParams.unitgroup == "util" then + return true + end + + return false +end + +return sharing diff --git a/modules/transfer/unit/comms.lua b/modules/transfer/unit/comms.lua new file mode 100644 index 00000000000..2818bd5bf32 --- /dev/null +++ b/modules/transfer/unit/comms.lua @@ -0,0 +1,181 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local TechBlockingComms = VFS.Include("modules/tech/blocking_comms.lua") + +local NONE_MODE = ModeEnums.UnitFilterCategory.None + +local Comms = {} +Comms.__index = Comms + +--- True when a higher tech level introduces a real unit-sharing change ("none"/empty unlocks nothing). +---@param policy UnitPolicyResult +---@return boolean +local function hasFutureUnlock(policy) + local tb = TechBlockingComms.fromPolicy(policy) + if not tb then + return false + end + local opts = Spring.GetModOptions() + for scanLevel = tb.level + 1, 3 do + local mode = opts["unit_sharing_mode_at_t" .. scanLevel] ---@type string? sparse modoption + if mode and mode ~= "" and mode ~= NONE_MODE then + return true + end + end + return false +end + +---@param modes string[] +---@return string +local function displayModes(modes) + local names = {} + for _, m in ipairs(modes) do + names[#names + 1] = Spring.I18N("ui.unitSharingMode." .. m) + end + return table.concat(names, " + ") +end + +---@param policy UnitPolicyResult +---@return table +local function techI18nData(policy) + local tb = TechBlockingComms.fromPolicy(policy) + if not tb then + return {} + end + local opts = Spring.GetModOptions() + local nextMode = nil + local nextLevel = nil + local nextThreshold = nil + for scanLevel = tb.level + 1, 3 do + local mode = opts["unit_sharing_mode_at_t" .. scanLevel] ---@type string? sparse modoption + if mode and mode ~= "" and mode ~= NONE_MODE then + nextMode = mode + nextLevel = scanLevel + nextThreshold = scanLevel == 2 and tb.t2Threshold or tb.t3Threshold + break + end + end + return { + currentKeystones = tb.points, + nextTechLevel = nextLevel or tb.nextLevel, + requiredKeystones = nextThreshold or tb.nextThreshold, + nextUnitSharingMode = nextMode or "", + } +end + +---@param policy UnitPolicyResult +---@param validationResult UnitValidationResult? +---@return number TransferEnums.UnitCommunicationCase +function Comms.DecideCommunicationCase(policy, validationResult) + if policy.senderTeamId == policy.receiverTeamId then + return TransferEnums.UnitCommunicationCase.OnSelf + elseif not policy.canShare and TechBlockingComms.fromPolicy(policy) then + return TransferEnums.UnitCommunicationCase.OnTechBlocked + elseif not policy.canShare then + return TransferEnums.UnitCommunicationCase.OnPolicyDisabled + elseif validationResult then + if validationResult.status == TransferEnums.UnitValidationOutcome.PartialSuccess then + return TransferEnums.UnitCommunicationCase.OnPartiallyShareable + elseif validationResult.status == TransferEnums.UnitValidationOutcome.Success then + return TransferEnums.UnitCommunicationCase.OnFullyShareable + else + return TransferEnums.UnitCommunicationCase.OnSelectionValidationFailed + end + else + return TransferEnums.UnitCommunicationCase.OnFullyShareable + end +end + +---Append share-time effect notes (build delay, stun) for the selection, gated on the validation's affected count. +---@param text string +---@param policy UnitPolicyResult +---@param validationResult UnitValidationResult? +---@return string +local function withPolicyEffects(text, policy, validationResult) + if not validationResult then + return text + end + + local buildDelay = tonumber(policy.buildDelaySeconds) or 0 + local builderCount = tonumber(validationResult.buildDelayedUnitCount) or 0 + if buildDelay > 0 and builderCount > 0 then + text = text .. " " .. Spring.I18N("ui.playersList.shareUnits.base.buildDelay", { + count = builderCount, + buildDelaySeconds = buildDelay, + }) + end + + local stunSeconds = tonumber(policy.stunSeconds) or 0 + local stunnedCount = tonumber(validationResult.stunnedUnitCount) or 0 + if stunSeconds > 0 and stunnedCount > 0 then + text = text .. " " .. Spring.I18N("ui.playersList.shareUnits.base.stunDelay", { + count = stunnedCount, + stunSeconds = stunSeconds, + stunCategory = policy.stunCategory and Spring.I18N("ui.unitSharingMode." .. policy.stunCategory) or "", + }) + end + + return text +end + +---@param policy UnitPolicyResult +---@param validationResult UnitValidationResult? +function Comms.TooltipText(policy, validationResult) + local tb = TechBlockingComms.fromPolicy(policy) + local hasTechUnlock = tb ~= nil + -- config that never unlocks anything new reads as a plain restriction, so use base messaging + local futureUnlock = hasTechUnlock and hasFutureUnlock(policy) + local tree = (hasTechUnlock and futureUnlock) and "tech" or "base" + local u = "ui.playersList.shareUnits." .. tree + local case = Comms.DecideCommunicationCase(policy, validationResult) + + if case == TransferEnums.UnitCommunicationCase.OnSelf then + return Spring.I18N("ui.playersList.requestSupport") + elseif case == TransferEnums.UnitCommunicationCase.OnTechBlocked then + if not futureUnlock then + return Spring.I18N("ui.playersList.shareUnits.tech.noUnlock") + end + return Spring.I18N(u .. ".disabled", techI18nData(policy)) + elseif case == TransferEnums.UnitCommunicationCase.OnPolicyDisabled then + return Spring.I18N(u .. ".disabled", { unitSharingMode = displayModes(policy.sharingModes) }) + elseif case == TransferEnums.UnitCommunicationCase.OnSelectionValidationFailed then + if hasTechUnlock and not futureUnlock then + return Spring.I18N("ui.playersList.shareUnits.tech.noUnlock") + end + local i18nData = { unitSharingMode = displayModes(policy.sharingModes) } + if tree == "tech" then + local td = techI18nData(policy) + for k, v in pairs(td) do + i18nData[k] = v + end + end + return Spring.I18N(u .. ".allInvalid", i18nData) + elseif case == TransferEnums.UnitCommunicationCase.OnPartiallyShareable then + if not validationResult then + error("This should not be possible.") + end + local invalidNames = validationResult.invalidUnitNames + local i18nData = { + unitSharingMode = displayModes(policy.sharingModes), + firstInvalidUnitName = invalidNames[1] or "", + count = #invalidNames, + } + if tree == "tech" then + local td = techI18nData(policy) + for k, v in pairs(td) do + i18nData[k] = v + end + end + return withPolicyEffects(Spring.I18N(u .. ".invalid", i18nData), policy, validationResult) + elseif case == TransferEnums.UnitCommunicationCase.OnFullyShareable then + local i18nData = {} + if validationResult then + i18nData.validUnitCount = validationResult.validUnitCount + end + return withPolicyEffects(Spring.I18N("ui.playersList.shareUnits.base.default", i18nData), policy, validationResult) + else + error("Invalid unit communication case: " .. case) + end +end + +return Comms diff --git a/modules/transfer/unit/shared.lua b/modules/transfer/unit/shared.lua new file mode 100644 index 00000000000..b12da00dbcf --- /dev/null +++ b/modules/transfer/unit/shared.lua @@ -0,0 +1,304 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local UnitCategories = VFS.Include("modules/context/unit_categories.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local PolicyShared = VFS.Include("modules/context/serialization.lua") +local UnitSharingCategories = VFS.Include("modules/transfer/unit/categories.lua") +local Comms = VFS.Include("modules/transfer/unit/comms.lua") + +local Shared = Comms + +local FieldTypes = PolicyShared.FieldTypes +Shared.UnitPolicyFields = { + canShare = FieldTypes.boolean, + sharingModes = FieldTypes.string, + -- carried to the widget so the share tooltip can surface the share-time effects + buildDelaySeconds = FieldTypes.number, + stunSeconds = FieldTypes.number, + stunCategory = FieldTypes.string, +} + +-- cached per-team factors; GetCachedPolicyResult rebuilds any pair on read (alliance + active stay live) +Shared.UnitFactorFields = { + sharingModes = FieldTypes.string, + active = FieldTypes.boolean, +} + +---Rules-param key for a team's unit factor record (owner team = the rules-param team). +---@return string +function Shared.MakeFactorKey() + return TransferEnums.PolicyType.UnitTransfer .. "_factor" +end + +---@param factor table {sharingModes: string[], active: boolean} +---@return string +function Shared.SerializeUnitFactor(factor) + return PolicyShared.Serialize(Shared.UnitFactorFields, { + sharingModes = table.concat(factor.sharingModes or { ModeEnums.UnitFilterCategory.None }, ","), + active = factor.active, + }) +end + +---@param serialized string +---@return table {sharingModes: string[], active: boolean} +function Shared.DeserializeUnitFactor(serialized) + local raw = PolicyShared.Deserialize(Shared.UnitFactorFields, serialized) + local modes = {} + for m in (raw.sharingModes or "none"):gmatch("[^,]+") do + modes[#modes + 1] = m + end + return { sharingModes = modes, active = raw.active } +end + +---@param unitDefID integer +---@param stunCategory string? +---@param defs table +---@return boolean +local function wouldBeStunned(unitDefID, stunCategory, defs) + if not stunCategory then + return false + end + return Shared.IsShareableDef(unitDefID, stunCategory, defs) +end + +---clear an array in place so a lifted result table can be reused without churning garbage +---@param arr table +local function resetArray(arr) + for i = #arr, 1, -1 do + arr[i] = nil + end +end + +---Validate a list of unitIds under current mode +---@param policyResult UnitPolicyResult +---@param unitIds integer[] +---@param springApi Spring? +---@param unitDefs table? +---@param out UnitValidationResult? optional pre-allocated result to fill in place (table lifting) +---@return UnitValidationResult +function Shared.ValidateUnits(policyResult, unitIds, springApi, unitDefs, out) + local spring = springApi or Spring + local defs = unitDefs or UnitDefs or (spring.GetUnitDefs and spring.GetUnitDefs()) or {} + + -- reuse caller's table when supplied; scalars reassigned and arrays cleared so stale entries never leak + out = out or ({} --[[@as UnitValidationResult]]) -- every field is (re)assigned below + out.status = TransferEnums.UnitValidationOutcome.Failure + out.validUnitCount = 0 + out.invalidUnitCount = 0 + out.buildDelayedUnitCount = 0 -- valid units that will receive the constructor build delay + out.stunnedUnitCount = 0 -- valid units that will be stunned (stun category) + out.validUnitNames = out.validUnitNames or {} + out.validUnitIds = out.validUnitIds or {} + out.invalidUnitNames = out.invalidUnitNames or {} + out.invalidUnitIds = out.invalidUnitIds or {} + resetArray(out.validUnitNames) + resetArray(out.validUnitIds) + resetArray(out.invalidUnitNames) + resetArray(out.invalidUnitIds) + + if (not policyResult.canShare) or (not unitIds or #unitIds == 0) then + return out + end + + local modes = policyResult.sharingModes or { "none" } + local stunSeconds = tonumber(policyResult.stunSeconds) or 0 + local stunCategory = policyResult.stunCategory + + -- dedupe sets stay call-local; a shared one would leak keys across rotating `out` tables + local validUnitNamesSet = {} ---@type table + local invalidUnitNamesSet = {} ---@type table + for _, unitId in ipairs(unitIds) do + local unitDefID = spring.GetUnitDefID(unitId) + if not unitDefID then + -- LOG.ERROR must stay numeric: the engine rejects numeric *strings* as log levels + spring.Log("unit_transfer_shared", LOG.ERROR, string.format("ValidateUnits: unitId %d not found", unitId)) + out.invalidUnitCount = out.invalidUnitCount + 1 + table.insert(out.invalidUnitIds, unitId) + if not invalidUnitNamesSet["Unknown Unit"] then + invalidUnitNamesSet["Unknown Unit"] = true + table.insert(out.invalidUnitNames, "Unknown Unit") + end + else + local ok = Shared.IsShareableDef(unitDefID, modes, defs) + local def = defs[unitDefID] or defs[tostring(unitDefID)] + local unitName = (def and (def.translatedHumanName or def.name)) or tostring(unitDefID) + + -- Block nanoframes for units that would be stunned (prevents tax bypass) + if ok and stunSeconds > 0 and wouldBeStunned(unitDefID, stunCategory, defs) then + local beingBuilt, buildProgress = spring.GetUnitIsBeingBuilt(unitId) + if beingBuilt and buildProgress > 0 then + ok = false + end + end + + if ok then + out.validUnitCount = out.validUnitCount + 1 + table.insert(out.validUnitIds, unitId) + if UnitSharingCategories.isMobileBuilderDef(def) then + out.buildDelayedUnitCount = out.buildDelayedUnitCount + 1 + end + if wouldBeStunned(unitDefID, stunCategory, defs) then + out.stunnedUnitCount = out.stunnedUnitCount + 1 + end + if not validUnitNamesSet[unitName] then + validUnitNamesSet[unitName] = true + table.insert(out.validUnitNames, unitName) + end + else + out.invalidUnitCount = out.invalidUnitCount + 1 + table.insert(out.invalidUnitIds, unitId) + if not invalidUnitNamesSet[unitName] then + invalidUnitNamesSet[unitName] = true + table.insert(out.invalidUnitNames, unitName) + end + end + end + end + + if out.validUnitCount > 0 and out.invalidUnitCount == 0 then + out.status = TransferEnums.UnitValidationOutcome.Success + elseif out.validUnitCount > 0 and out.invalidUnitCount > 0 then + out.status = TransferEnums.UnitValidationOutcome.PartialSuccess + else + out.status = TransferEnums.UnitValidationOutcome.Failure + end + + return out +end + +---rebuild (sender,receiver) unit policy from cached factors + live gates (mirrors Synced.GetPolicy); missing factors fall back to global unit_sharing_mode +---@param senderTeamId integer +---@param receiverTeamId integer +---@param springApi Spring? +---@return UnitPolicyResult +function Shared.GetCachedPolicyResult(senderTeamId, receiverTeamId, springApi) + local spring = springApi or Spring + local modOptions = spring.GetModOptions() + local stunSeconds = tonumber(modOptions[ModeEnums.ModOptions.UnitShareStunSeconds]) or 0 + local stunCategory = modOptions[ModeEnums.ModOptions.UnitStunCategory] or ModeEnums.UnitFilterCategory.Resource + local buildDelaySeconds = tonumber(modOptions[ModeEnums.ModOptions.ConstructorBuildDelay]) or 0 + + local areAllied = (spring.AreTeamsAllied and spring.AreTeamsAllied(senderTeamId, receiverTeamId)) == true + + local factorKey = Shared.MakeFactorKey() + local senderSerialized = spring.GetTeamRulesParam(senderTeamId, factorKey) + local receiverSerialized = spring.GetTeamRulesParam(receiverTeamId, factorKey) + + if senderSerialized == nil or receiverSerialized == nil then + -- Pre-cache fallback: global mode + alliance only (matches legacy behaviour). + local category = modOptions.unit_sharing_mode or ModeEnums.UnitFilterCategory.None + ---@type UnitPolicyResult + return { + senderTeamId = senderTeamId, + receiverTeamId = receiverTeamId, + canShare = areAllied and category ~= ModeEnums.UnitFilterCategory.None, + sharingModes = { category }, + stunSeconds = stunSeconds, + stunCategory = stunCategory, + buildDelaySeconds = buildDelaySeconds, + } + end + + local senderFactor = Shared.DeserializeUnitFactor(senderSerialized) + local receiverFactor = Shared.DeserializeUnitFactor(receiverSerialized) + local modes = senderFactor.sharingModes + local modeNotNone = not (#modes == 1 and modes[1] == ModeEnums.UnitFilterCategory.None) + + local canShare = areAllied and modeNotNone + if canShare and not (spring.IsCheatingEnabled and spring.IsCheatingEnabled()) then + if not receiverFactor.active then + canShare = false + end + end + + ---@type UnitPolicyResult + return { + senderTeamId = senderTeamId, + receiverTeamId = receiverTeamId, + canShare = canShare, + sharingModes = modes, + stunSeconds = stunSeconds, + stunCategory = stunCategory, + buildDelaySeconds = buildDelaySeconds, + } +end + +function Shared.GetModeUnitTypes(category) + -- Vocabulary, not mechanism: context owns which types a category covers, + -- because construction gates on the same answer and cannot ask transfer. + return UnitCategories.TypesFor(category) +end + +local function UnitTypeMatchesCategory(unitDef, category) + local unitType = UnitSharingCategories.classifyUnitDef(unitDef) + local categoryUnitTypes = Shared.GetModeUnitTypes(category) + return table.contains(categoryUnitTypes, unitType) +end + +---@param unitDef table +---@param category string +---@return boolean +local function EvaluateUnitForSharing(unitDef, category) + if not unitDef then + return false + end + + if category == ModeEnums.UnitFilterCategory.None then + return false + end + + if category == ModeEnums.UnitFilterCategory.All then + return true + end + + return UnitTypeMatchesCategory(unitDef, category) +end + +local allowedByCategory = setmetatable({}, { __mode = "k" }) + +local function BuildAllowedCacheForCategory(category, unitDefs) + local defs = unitDefs or UnitDefs + if not defs then + return nil + end + local cacheByDefs = allowedByCategory[defs] + if not cacheByDefs then + cacheByDefs = {} + allowedByCategory[defs] = cacheByDefs + end + if cacheByDefs[category] then + return cacheByDefs[category] + end + local cache = {} + for unitDefID, unitDef in pairs(defs) do + if EvaluateUnitForSharing(unitDef, category) then + cache[unitDefID] = true + end + end + cacheByDefs[category] = cache + return cache +end + +---@param unitDefId number +---@param categories string|string[] +---@param unitDefs table? +---@return boolean +function Shared.IsShareableDef(unitDefId, categories, unitDefs) + if not unitDefId or not categories then + return false + end + if type(categories) == "string" then + categories = { categories } + end + for _, category in ipairs(categories) do + if category == ModeEnums.UnitFilterCategory.All then + return true + end + local cache = BuildAllowedCacheForCategory(category, unitDefs) + if cache and cache[unitDefId] then + return true + end + end + return false +end + +return Shared diff --git a/modules/transfer/unit/synced.lua b/modules/transfer/unit/synced.lua new file mode 100644 index 00000000000..594387fc44b --- /dev/null +++ b/modules/transfer/unit/synced.lua @@ -0,0 +1,79 @@ +local ModeEnums = VFS.Include("modules/context/mode_enums.lua") +local TransferEnums = VFS.Include("modules/context/enums.lua") +local ModuleHandler = VFS.Include("modules/module_handler.lua") +local Shared = VFS.Include("modules/transfer/unit/shared.lua") +local PolicyEvents = VFS.Include("modules/context/policy_events.lua") + +local Synced = { + ValidateUnits = Shared.ValidateUnits, + GetModeUnitTypes = Shared.GetModeUnitTypes, +} + +---Build per-pair policy (expose) from context +---@param ctx PolicyContext +---@return UnitPolicyResult +function Synced.GetPolicy(ctx) + -- Declared as a named stage in policies/unit_transfer.lua. + return ModuleHandler.Evaluate(ModuleHandler.LoadPolicies("transfer").unit_transfer, ctx) +end + +---Execute unit transfer with pre-validated units +---@param ctx UnitTransferContext +---@return UnitTransferResult +function Synced.UnitTransfer(ctx) + local policyResult = ctx.policyResult + + if not policyResult.canShare then + ---@type UnitTransferResult + return { + success = false, + outcome = TransferEnums.UnitValidationOutcome.Failure, + senderTeamId = ctx.senderTeamId, + receiverTeamId = ctx.receiverTeamId, + validationResult = ctx.validationResult, + policyResult = ctx.policyResult, + } + end + + for _, unitId in ipairs(ctx.validationResult.validUnitIds) do + -- ctx.given should always be false here because we short-circuit inside AllowResourceTransfer + ctx.springRepo.TransferUnit(unitId, ctx.receiverTeamId, ctx.given) + end + + ---@type UnitTransferResult + return { + success = true, + outcome = ctx.validationResult.status, + senderTeamId = ctx.senderTeamId, + receiverTeamId = ctx.receiverTeamId, + validationResult = ctx.validationResult, + policyResult = ctx.policyResult, + } +end + +---@param springRepo Spring +---@param teamId integer +---@return boolean +local function teamActive(springRepo, teamId) + local n = springRepo.GetTeamRulesParam(teamId, "numActivePlayers") + if n == nil then + return true + end + return tonumber(n) ~= 0 +end + +---Compute and cache one team's unit factor: its tech-resolved sharing modes + active flag. +---@param springRepo Spring +---@param teamId integer +---@param ctx PolicyContext self-context (sender==receiver==teamId) so the enricher resolves the team's modes +function Synced.CacheTeamFactor(springRepo, teamId, ctx) + local modes = ctx.unitSharingModes or { springRepo.GetModOptions().unit_sharing_mode or ModeEnums.UnitFilterCategory.None } + local serialized = Shared.SerializeUnitFactor({ + sharingModes = modes, + active = teamActive(springRepo, teamId), + }) + springRepo.SetTeamRulesParam(teamId, Shared.MakeFactorKey(), serialized) + PolicyEvents.NotifyIfChanged(teamId, TransferEnums.PolicyType.UnitTransfer, serialized) +end + +return Synced diff --git a/modules/transfer/unit/unsynced.lua b/modules/transfer/unit/unsynced.lua new file mode 100644 index 00000000000..cf7c5b0aeea --- /dev/null +++ b/modules/transfer/unit/unsynced.lua @@ -0,0 +1,16 @@ +local LuaRulesMsg = VFS.Include("common/luaUtilities/lua_rules_msg.lua") + +local Unsynced = {} + +---Share selected units to target team; synced controller re-validates, so just forward the selection. +---@param targetTeamID number +function Unsynced.ShareUnits(targetTeamID) + local unitIDs = Spring.GetSelectedUnits() + if #unitIDs == 0 then + return + end + local msg = LuaRulesMsg.SerializeUnitTransfer(targetTeamID, unitIDs) + Spring.SendLuaRulesMsg(msg) +end + +return Unsynced diff --git a/modules/transfer/unsynced.lua b/modules/transfer/unsynced.lua new file mode 100644 index 00000000000..6c86f0d13e3 --- /dev/null +++ b/modules/transfer/unsynced.lua @@ -0,0 +1,11 @@ +local ResourceShared = VFS.Include("modules/transfer/resource/shared.lua") +local UnitShared = VFS.Include("modules/transfer/unit/shared.lua") +local UnitUnsynced = VFS.Include("modules/transfer/unit/unsynced.lua") + +local SharingUnsynced = {} + +SharingUnsynced.Resources = ResourceShared +SharingUnsynced.Units = UnitShared +SharingUnsynced.Units.ShareUnits = UnitUnsynced.ShareUnits + +return SharingUnsynced diff --git a/modules/transfer/widgets/cmd_take.lua b/modules/transfer/widgets/cmd_take.lua new file mode 100644 index 00000000000..f8dbb5af9f0 --- /dev/null +++ b/modules/transfer/widgets/cmd_take.lua @@ -0,0 +1,18 @@ +function widget:GetInfo() + return { + name = "Take Command", + desc = "Catches /take and forwards to synced gadget", + author = "Antigravity", + date = "2024", + license = "GPL-v2", + layer = 0, + enabled = true, + } +end + +function widget:TextCommand(command) + if command:lower() == "take" then + Spring.SendLuaRulesMsg("take_cmd") + return true + end +end diff --git a/spec/builders/index.lua b/spec/builders/index.lua index 19e135925ec..8da46eab63b 100644 --- a/spec/builders/index.lua +++ b/spec/builders/index.lua @@ -7,10 +7,12 @@ local UnitDefsBuilder = VFS.Include("spec/builders/unit_defs_builder.lua") ---@class Builders ---@field Team TeamBuilder ----@field Spring SpringBuilder +---@field Spring SpringSyncedBuilder ---@field SpringUnsynced SpringUnsyncedBuilder +---@field ResourceData ResourceDataBuilder ---@field UnitDef UnitDefBuilder ---@field UnitDefs UnitDefsBuilder +---@field Mode table local Builders = { Team = TeamBuilder, Spring = SpringSyncedBuilder, @@ -20,4 +22,16 @@ local Builders = { UnitDefs = UnitDefsBuilder, } -return Builders \ No newline at end of file +-- Mode helpers pull in the policy pipeline (context_factory / resource_transfer_synced), +-- so load them lazily: specs that never touch Builders.Mode don't drag the pipeline into +-- their layer, which keeps the lower stacked PRs (library, modes/economy) self-contained. + +return setmetatable( Builders, { + __index = function(t, k) + if k == "Mode" then + local mod = VFS.Include("spec/builders/mode_test_helpers.lua") + rawset(t, "Mode", mod) + return mod + end + end, +}) diff --git a/spec/builders/mode_test_helpers.lua b/spec/builders/mode_test_helpers.lua new file mode 100644 index 00000000000..aa0b7115999 --- /dev/null +++ b/spec/builders/mode_test_helpers.lua @@ -0,0 +1,73 @@ +local ContextFactoryModule = VFS.Include("modules/context/context_factory.lua") +local ResourceTransfer = VFS.Include("modules/transfer/resource/synced.lua") +local SharedConfig = VFS.Include("modules/transfer/economy/shared_config.lua") + +local M = {} + +function M.modeModOpts(modeConfig) + local opts = {} + for key, entry in pairs(modeConfig.modOptions) do + local value = entry.value + if type(value) == "boolean" then + opts[key] = value and "1" or "0" + else + opts[key] = tostring(value) + end + end + return opts +end + +function M.buildModeResult(spring, modeConfig, sender, receiver, resourceType, enricherFn) + local springApi = spring:Build() + springApi.GetModOptions = function() + return M.modeModOpts(modeConfig) + end + SharedConfig.resetCache() + + local saved = ContextFactoryModule.getEnrichers() + ContextFactoryModule.setEnrichers(enricherFn and { enricherFn } or {}) + + local ctx = ContextFactoryModule.create(springApi).policy(sender.id, receiver.id) + local result = ResourceTransfer.CalcResourcePolicy(ctx, resourceType) + + ContextFactoryModule.setEnrichers(saved) + return result +end + +function M.snapshotResult(result) + local snap = {} + for k, v in pairs(result) do + if type(v) == "table" then + local copy = {} + for k2, v2 in pairs(v) do + copy[k2] = v2 + end + snap[k] = copy + else + snap[k] = v + end + end + return snap +end + +function M.buildModeTransfer(spring, modeConfig, sender, receiver, resourceType, desiredAmount, enricherFn) + local springApi = spring:Build() + springApi.GetModOptions = function() + return M.modeModOpts(modeConfig) + end + SharedConfig.resetCache() + + local saved = ContextFactoryModule.getEnrichers() + ContextFactoryModule.setEnrichers(enricherFn and { enricherFn } or {}) + + local policyCtx = ContextFactoryModule.create(springApi).policy(sender.id, receiver.id) + local policyResult = M.snapshotResult(ResourceTransfer.CalcResourcePolicy(policyCtx, resourceType)) + + local transferCtx = ContextFactoryModule.create(springApi).resourceTransfer(sender.id, receiver.id, resourceType, desiredAmount, policyResult) + local result = ResourceTransfer.ResourceTransfer(transferCtx) + + ContextFactoryModule.setEnrichers(saved) + return result, policyResult +end + +return M diff --git a/spec/builders/sequence.lua b/spec/builders/sequence.lua index eebace2ad2a..e17a4aa397f 100644 --- a/spec/builders/sequence.lua +++ b/spec/builders/sequence.lua @@ -7,14 +7,17 @@ local M = {} -- private counter store (prefix -> next integer) -local _counters = {} +local _counters = {} ---@type table ---@class SequenceOptions ---@field start integer|nil -- first number to emit (default 1) ---@field step integer|nil -- increment step (default 1) ----@field format fun(prefix:string, n:integer):string|nil -- optional formatter +---@field format (fun(prefix:string, n:integer): string|integer)|nil -- optional formatter ---Get current counter for prefix (or defaultStart - 1 if unset) +---@param prefix string +---@param defaultStart integer? +---@return integer local function current(prefix, defaultStart) local n = _counters[prefix] if n == nil then return (defaultStart or 1) - 1 end @@ -24,9 +27,9 @@ end ---Create a generator function tied to a prefix (& cached counter) ---@param prefix string ---@param opts SequenceOptions|nil ----@return fun():string +---@return fun(): string|integer function M.sequence(prefix, opts) - opts = opts or {} + opts = opts or ( {} --[[@as SequenceOptions]]) local start = opts.start or 1 local step = opts.step or 1 local fmt = opts.format or function(p, n) return p .. tostring(n) end @@ -35,7 +38,7 @@ function M.sequence(prefix, opts) if _counters[prefix] == nil then _counters[prefix] = start end return function() - local n = _counters[prefix] + local n = _counters[prefix] --[[@as integer]] -- initialized above _counters[prefix] = n + step local str = fmt(prefix, n) if str == nil then str = prefix .. tostring(n) end diff --git a/spec/builders/spring_synced_builder.lua b/spec/builders/spring_synced_builder.lua index b51a519f9f5..376ff5cab14 100644 --- a/spec/builders/spring_synced_builder.lua +++ b/spec/builders/spring_synced_builder.lua @@ -7,12 +7,18 @@ VFS.Include("common.tablefunctions.lua") local UnitDefsBuilder = VFS.Include("spec/builders/unit_defs_builder.lua") ----@class SpringSyncedMock : SpringSynced ----@field GetUnitDefs fun(): table ----@field GetUnitDefNames fun(): table ----@field GetPlayerListUnpacked fun(): TeamData[]? ----@field GetPlayerIdsList fun(): number[]? ----@field _builtTeams table +---@class SpringSyncedMock : Spring +---@field GetUnitDefs fun(): table +---@field GetUnitDefNames fun(): table? +---@field GetPlayerListUnpacked fun(): TeamDataMock[]? +---@field GetPlayerIdsList fun(): integer[]? +---@field GetPlayerList fun(teamID: integer?): integer[] +---@field GetTeamAllyTeamID fun(teamID: integer): integer? +---@field SetTeamShareLevel fun(teamID: integer, resource: ResourceName, level: number) +---@field GiveOrderToUnit fun(unitID: integer, cmdID: integer, params: table?, options: table?): boolean +---@field ValidUnitID fun(unitID: integer): boolean +---@field CMD table +---@field _builtTeams table ---@field setDataCalls table ---@field __resourceSetCalls table ---@field __clearResourceDataCalls fun() @@ -20,14 +26,17 @@ local UnitDefsBuilder = VFS.Include("spec/builders/unit_defs_builder.lua") ---@field __getInitialUnits fun(): table ---@class SpringSyncedBuilder : SpringSyncedMock ----@field modOptions table ----@field teamRulesParams table ----@field teams table +---@field modOptions table +---@field teamRulesParams table> +---@field teams table ---@field logMessages table ----@field alliances table ----@field gameFrame number +---@field alliances table> +---@field gameFrame integer ---@field cheatingEnabled boolean ----@field initialUnits table> +---@field initialUnits table> +---@field unitDefs UnitDefsBuilder +---@field _globalUnitDefs table? +---@field _globalUnitDefNames table? local SB = {} SB.__index = SB @@ -118,7 +127,6 @@ end local function buildUnitDefIndex(unitDefs, unitDefNames) local index = {} for key, def in pairs(unitDefs or {}) do - if def then normalizeUnitDef(def) index[key] = def if def.id then @@ -126,7 +134,6 @@ local function buildUnitDefIndex(unitDefs, unitDefNames) end if def.name then index[def.name] = def - end end end for name, info in pairs(unitDefNames or {}) do @@ -141,7 +148,7 @@ end ---@return SpringSyncedBuilder function SB.new() return setmetatable({ - modOptions = { game_economy = "1" }, + modOptions = { }, teamRulesParams = {}, -- teamID -> paramName -> value teams = {}, -- teamID -> TeamDataMock from team builders logMessages = {}, @@ -152,8 +159,6 @@ function SB.new() _globalUnitDefs = nil, -- mirror of unitDefs:GetUnitDefsByName() once loaded }, SB) end - ----@param self SpringSyncedBuilder ---@param options table ---@return SpringSyncedBuilder function SB:WithModOptions(options) @@ -161,24 +166,19 @@ function SB:WithModOptions(options) for key, value in pairs(options or {}) do self.modOptions[key] = value end - self.modOptions.game_economy = "1" return self end - ----@param self SpringSyncedBuilder ---@param key string ---@param value any ---@return SpringSyncedBuilder function SB:WithModOption(key, value) self.modOptions[key] = value - self.modOptions.game_economy = "1" return self end - ----@param self SpringSyncedBuilder ----@param team1ID number ----@param team2ID number +---@param team1ID integer +---@param team2ID integer +---@param isAllied boolean? ---@return SpringSyncedBuilder function SB:WithAlliance(team1ID, team2ID, isAllied) if isAllied == nil then isAllied = true end -- Default to allied for backward compatibility @@ -189,16 +189,14 @@ function SB:WithAlliance(team1ID, team2ID, isAllied) return self end ----@param self SpringSyncedBuilder ----@param frame number +---@param frame integer ---@return SpringSyncedBuilder function SB:WithGameFrame(frame) self.gameFrame = frame return self end ----@param self SpringSyncedBuilder ----@param teamID number +---@param teamID integer ---@param key string ---@param value any ---@return SpringSyncedBuilder @@ -207,21 +205,17 @@ function SB:WithTeamRulesParam(teamID, key, value) self.teamRulesParams[teamID][key] = value return self end - ----@param self SpringSyncedBuilder ---@return SpringSyncedMock function SB:Build() return self:BuildSpring() end - ----@param self SpringSyncedBuilder ---@return SpringSyncedMock function SB:BuildSpring() ---@type SpringSyncedBuilder local instance = self -- Build all teams for use throughout the repository - local builtTeams = {} + local builtTeams = {} ---@type table for teamId, teamBuilder in pairs(instance.teams) do builtTeams[teamId] = teamBuilder:Build() end @@ -236,7 +230,7 @@ function SB:BuildSpring() local defKey = unitWrapper.unitDefId local unitDef = defKey and instance._globalUnitDefs[defKey] if not unitDef and defKey and instance._globalUnitDefNames then - local info = instance._globalUnitDefNames[defKey] + local info = instance._globalUnitDefNames[defKey --[[@as string]]] local numericId = info and info.id if numericId then unitDef = instance._globalUnitDefs[numericId] @@ -263,6 +257,8 @@ function SB:BuildSpring() -- Use the team rules params configured via WithTeamRulesParam local rulesParams = instance.teamRulesParams + ---@param teamID integer + ---@return TeamDataMock local function ensureTeam(teamID) if type(teamID) ~= "number" then error(string.format("TeamID must be a number, got %s: %s", type(teamID), tostring(teamID))) @@ -318,7 +314,7 @@ function SB:BuildSpring() ---@type SpringSyncedMock local mock = { - CMD = Spring and Spring.CMD or { + CMD = ( Spring and ( Spring --[[@as table]]).CMD) or { LOAD_ONTO = 1, SELFD = 2, GUARD = 25, @@ -330,7 +326,8 @@ function SB:BuildSpring() return instance.modOptions end, GetGameFrame = function() - return instance.gameFrame + -- engine returns (frameNum % dayFrames, frameNum / dayFrames); tests only read the first + return instance.gameFrame, 0 end, IsCheatingEnabled = function() return instance.cheatingEnabled @@ -372,7 +369,7 @@ function SB:BuildSpring() local country = player.country or "XX" local rank = player.rank or 0 local hasSkirmishAIsInTeam = player.hasSkirmishAIsInTeam or false - local playerOpts = player.playerOpts or {} + local playerOpts = ( player.playerOpts or {}) --[[@as { [string]: string }]] local desynced = player.desynced or false return name, active, spectator, teamID, allyTeamID, pingTime, cpuUsage, country, rank, hasSkirmishAIsInTeam, playerOpts, desynced end @@ -384,7 +381,7 @@ function SB:BuildSpring() GetTeamResources = function(teamID, resourceType) local data = getResourceStore(teamID, resourceType) - return data.current, data.storage, data.pull, data.income, data.expense, data.shareSlider, data.sent, data.received + return data.current, data.storage, data.pull or 0, data.income or 0, data.expense or 0, data.shareSlider, data.sent, data.received, data.excess end, -- Convenience accessors for tests __getInitialUnits = function() @@ -404,9 +401,7 @@ function SB:BuildSpring() for teamId, teamBuilder in pairs(builtTeams) do if teamBuilder.units then for unitId, unitWrapper in pairs(teamBuilder.units) do - if unitWrapper.unitDefId then registeredUnitDefIds[unitWrapper.unitDefId] = true - end end end end @@ -516,10 +511,11 @@ function SB:BuildSpring() return id end if unitWrapper.unitDef and type(unitWrapper.unitDef.id) == "number" then - unitWrapper.unitDefId = unitWrapper.unitDef.id - return unitWrapper.unitDefId + unitWrapper.unitDefId = unitWrapper.unitDef.id --[[@as integer]] + return unitWrapper.unitDefId --[[@as integer]] end - return id + -- mock quirk: unresolved defIDs pass the name through (polyglot defs index) + return id --[[@as integer?]] end end end @@ -531,15 +527,20 @@ function SB:BuildSpring() end, AddTeamResource = function(teamID, resourceType, amount) - local teamData = builtTeams[teamID] - if teamData then - if resourceType == "metal" then - teamData.metal.current = teamData.metal.current + amount - elseif resourceType == "energy" then - teamData.energy.current = teamData.energy.current + amount - end - end - return true, amount + amount = math.max(0, amount) -- engine clamps the amount to >= 0 + local store = getResourceStore(teamID, resourceType) + store.current = store.current + amount + end, + + SetTeamResource = function(teamID, resourceType, value) + local store = getResourceStore(teamID, resourceType) + store.current = math.max(0, value) + end, + + AddTeamResourceExcessStats = function(teamID, resourceType, excess) + -- engine records only excess now; sent/received are Lua-owned (ShareStats) + local store = getResourceStore(teamID, resourceType) + store.excess = math.max(0, excess or 0) end, ValidUnitID = function(unitID) @@ -585,12 +586,12 @@ function SB:BuildSpring() end if not builtTeams[newTeamID] then - builtTeams[newTeamID] = {units = {}} + builtTeams[newTeamID] = {units = {}} --[[@as TeamDataMock]] end if not builtTeams[newTeamID].units then builtTeams[newTeamID].units = {} end - builtTeams[newTeamID].units[unitID] = {unitDefId = unitDefID} + builtTeams[newTeamID].units[unitID] = {unitDefId = unitDefID --[[@as integer|string]]} return true end, @@ -607,21 +608,20 @@ function SB:BuildSpring() return -1 end, - GetTeamInfo = function(teamID, getUnread) + GetTeamInfo = function(teamID, getTeamKeys) + -- mirrors the engine's return order: teamID, leader, isDead, hasAI, side, allyTeam, incomeMultiplier, customTeamKeys local teamData = builtTeams[teamID] if teamData then - local name = teamData.name or ("Team " .. tostring(teamID)) local leader = teamData.leader or 0 local isDead = teamData.isDead or false local isAI = teamData.isAI or false local side = teamData.side or "arm" local allyTeam = teamData.allyTeam or teamID - local customTeamKeys = teamData.customTeamKeys or {} local incomeMultiplier = teamData.incomeMultiplier or 1 - local customOpts = teamData.customOpts or 0 - return name, leader, isDead, isAI, side, allyTeam, customTeamKeys, incomeMultiplier, customOpts + local customTeamKeys = teamData.customTeamKeys or {} + return teamID, leader, isDead, isAI, side, allyTeam, incomeMultiplier, customTeamKeys end - return "Unknown", 0, true, false, "arm", -1, {}, 1, 0 + return nil, 0, true, false, "arm", -1, 1, {} end, GetTeamLuaAI = function(teamID) @@ -644,10 +644,6 @@ function SB:BuildSpring() end end, - GetAuditTimer = function() - return 0 - end, - GetTeamAllyTeamID = function(teamID) local teamData = builtTeams[teamID] if teamData then @@ -683,7 +679,6 @@ function SB:BuildSpring() end ---Temporarily install minimal global Spring/VFS/Game/LOG (spec_helper does some of this but we try for thoroughness) to allow real unitdefs load ----@param self SpringSyncedBuilder ---@param fn fun() ---@param persist? boolean If true, don't clean up globals after execution function SB:WithGlobalsDefined(fn, persist) @@ -698,6 +693,7 @@ function SB:WithGlobalsDefined(fn, persist) local prevUnitDefNames = _G.UnitDefNames -- Set up mocks for the duration of the function + ---@diagnostic disable-next-line: global-in-non-module _G.Spring = _G.Spring or {} local mock = self:BuildSpring() @@ -736,10 +732,12 @@ function SB:WithGlobalsDefined(fn, persist) if not _G.Spring.GetConfigInt then _G.Spring.GetConfigInt = function(name, default) return default or 0 end end - _G.Spring.Utilities = _G.Spring.Utilities or { Gametype = { IsScavengers = function() return false end, IsRaptors = function() return false end, GetCurrentHolidays = function() return {} end } } + ---@diagnostic disable-next-line: global-in-non-module + local gametypeStub = { IsScavengers = function() return false end, IsRaptors = function() return false end, GetCurrentHolidays = function() return {} end } + _G.Spring.Utilities = _G.Spring.Utilities or { Gametype = gametypeStub } -- Mock VFS.Include cache to intercept system.lua load - local originalVFSInclude = _G.VFS.Include + local originalVFSInclude = _G.VFS.Include ---@type function? _G.VFS.Include = function(path, ...) if path == "gamedata/system.lua" then return { @@ -776,6 +774,8 @@ function SB:WithGlobalsDefined(fn, persist) return {} end + ---@diagnostic disable: global-in-non-module -- deliberate test-env global setup + _G.LOG = _G.LOG or { DEBUG = "DEBUG", INFO = "INFO", WARNING = "WARNING", ERROR = "ERROR" } _G.Game = _G.Game or {} _G.Game.gameSpeed = _G.Game.gameSpeed or 30 @@ -795,6 +795,7 @@ function SB:WithGlobalsDefined(fn, persist) _G.select = select _G.next = next _G.require = require + ---@diagnostic enable: global-in-non-module -- Execute the function with globals set up local success, result = pcall(fn) @@ -804,20 +805,21 @@ function SB:WithGlobalsDefined(fn, persist) -- If not persisting, restore original globals if not persist then + ---@diagnostic disable-next-line: global-in-non-module _G.Spring = prevSpring _G.VFS = prevVFS _G.Game = prevGame _G.LOG = prevLOG + ---@diagnostic disable-next-line: global-in-non-module string.split = prevSplit + ---@diagnostic disable-next-line: global-in-non-module _G.UnitDefs = prevUnitDefs + ---@diagnostic disable-next-line: global-in-non-module _G.UnitDefNames = prevUnitDefNames end return instance end - - ----@param self SpringSyncedBuilder ---@param teamBuilder TeamBuilder The team builder instance ---@return SpringSyncedBuilder function SB:WithTeam(teamBuilder) @@ -831,7 +833,7 @@ end ---Register a unit definition. Accepts either a UnitDefBuilder or a (defID, defTable) pair. ---Delegates to the shared UnitDefsBuilder registry. ---@overload fun(self: SpringSyncedBuilder, udb: UnitDefBuilder): SpringSyncedBuilder ----@param defID number +---@param defID integer ---@param def table ---@return SpringSyncedBuilder function SB:WithUnitDef(defID, def) @@ -843,7 +845,6 @@ end ---Uses WithGlobalsDefined as the harness so modoptions are honored during the load. ---After loading, normalizes the defs into the polyglot index that downstream code ---(GetUnitDefs, BuildSpring team-resolution) expects. ----@param self SpringSyncedBuilder ---@return SpringSyncedBuilder function SB:WithRealUnitDefs() if self._globalUnitDefs then return self end diff --git a/spec/builders/spring_unsynced_builder.lua b/spec/builders/spring_unsynced_builder.lua index b79b3a80c17..401fee578da 100644 --- a/spec/builders/spring_unsynced_builder.lua +++ b/spec/builders/spring_unsynced_builder.lua @@ -31,7 +31,7 @@ end ---Register a unit definition. Delegates to the underlying UnitDefsBuilder. ---@overload fun(self: SpringUnsyncedBuilder, udb: UnitDefBuilder): SpringUnsyncedBuilder ----@param defID number +---@param defID integer ---@param def table ---@return SpringUnsyncedBuilder function SUB:WithUnitDef(defID, def) @@ -40,8 +40,8 @@ function SUB:WithUnitDef(defID, def) end ---Place a live unit instance on the map. Errors if the def is not registered. ----@param unitID number ----@param defIDOrName number|string +---@param unitID integer +---@param defIDOrName integer|string ---@return SpringUnsyncedBuilder function SUB:WithUnit(unitID, defIDOrName) self.unitDefs:WithUnit(unitID, defIDOrName) @@ -86,7 +86,7 @@ local function makeEnv(self) ---@diagnostic disable-next-line: missing-fields env.widget = {} env.GL = {} - env.Platform = { gl = not self.headless } + env.Platform = { gl = not self.headless, isHeadless = self.headless } env.WG = {} env.Game = { footprintScale = 2, @@ -145,8 +145,11 @@ local function makeEnv(self) springTable[k] = v end env.Spring = setmetatable(springTable, { __index = _G.Spring }) + -- spring-split: widgets call Engine.{Synced,Unsynced,Shared}.X; point all + -- buckets at the same mock table so they resolve to the per-test overrides. + env.Engine = { Synced = env.Spring, Unsynced = env.Spring, Shared = env.Spring } - local realInclude = _G.VFS and _G.VFS.Include + local realInclude = ( _G.VFS and _G.VFS.Include) --[[@as function?]] local includeOverrides = self.vfsIncludeOverrides env.VFS = setmetatable({ Include = function(path, ...) @@ -184,7 +187,7 @@ function SUB:LoadWidget(widgetPath) function mock.captureArrayOrders() local calls = {} - env.Spring.GiveOrderArrayToUnitArray = function(unitIDs, orders, _) + env.Engine.Shared.GiveOrderArrayToUnitArray = function(unitIDs, orders, _) table.insert(calls, { unitIDs = unitIDs, orders = orders }) end return calls @@ -192,7 +195,7 @@ function SUB:LoadWidget(widgetPath) function mock.captureUnitOrders() local calls = {} - env.Spring.GiveOrderToUnit = function(unitID, cmdID, params, _) + env.Engine.Shared.GiveOrderToUnit = function(unitID, cmdID, params, _) table.insert(calls, { unitID = unitID, cmdID = cmdID, params = params }) end return calls diff --git a/spec/builders/team_builder.lua b/spec/builders/team_builder.lua index 658d73af765..50e52d75a59 100644 --- a/spec/builders/team_builder.lua +++ b/spec/builders/team_builder.lua @@ -1,21 +1,25 @@ -- Team Builder -- Builds individual team/player configurations with automatic ID generation -local Sides = require("gamedata/sides_enum") local Definitions = require("luaui/Include/blueprint_substitution/definitions") + +local Sides = require("gamedata/sides_enum") local ResourceDataBuilder = VFS.Include("spec/builders/resource_data_builder.lua") local sequence = require("spec/builders/sequence") local nextPlayerId = sequence.sequence("player_id", { start = 100, format = function(_, n) return n end }) ----@class TeamDataMock : TeamData +---@class TeamDataMock : TeamData, TeamResourceData ---@field isHuman boolean ---@field playerName string ----@field units table ----@field players PlayerData[] +---@field units table? +---@field players PlayerData[]? ---@field metal ResourceData ---@field energy ResourceData +---@field incomeMultiplier number? +---@field customTeamKeys table? +---@field luaAI string? ---@class TeamBuilder : TeamDataMock local TeamBuilder = {} @@ -51,8 +55,9 @@ local defaultData = { local nextTeamId = sequence.sequence("team_id", { start = 0, format = function(_, n) return n end }) local nextUnitId = sequence.sequence("unit_id", { start = 1, format = function(p, n) return tostring(n) end }) +---@return TeamBuilder function TeamBuilder.new() - local instance = {} + local instance = {} ---@type table -- Copy default data for k, v in pairs(defaultData) do @@ -66,8 +71,8 @@ function TeamBuilder.new() end -- Assign unique IDs - team ID and player ID are deliberately different - instance.id = tonumber(nextTeamId()) - local defaultLeaderPlayerId = tonumber(nextPlayerId()) + instance.id = tonumber(nextTeamId()) --[[@as integer]] + local defaultLeaderPlayerId = tonumber(nextPlayerId()) --[[@as integer]] instance.leader = defaultLeaderPlayerId instance.allyTeam = instance.id @@ -88,9 +93,11 @@ function TeamBuilder.new() } } - return setmetatable(instance, { __index = TeamBuilder }) + return setmetatable(instance, { __index = TeamBuilder }) --[[@as TeamBuilder]] end +---@param teamID integer +---@return TeamBuilder function TeamBuilder:WithID(teamID) if type(teamID) ~= "number" then error("TeamBuilder:WithID requires a numeric teamID") @@ -99,6 +106,8 @@ function TeamBuilder:WithID(teamID) return self end +---@param allyTeamID integer +---@return TeamBuilder function TeamBuilder:WithAllyTeam(allyTeamID) if type(allyTeamID) ~= "number" then error("TeamBuilder:WithAllyTeam requires a numeric allyTeamID") @@ -126,22 +135,18 @@ function TeamBuilder:Build() } return out end - ----@param self TeamBuilder ---@param metal number ---@return TeamBuilder function TeamBuilder:WithMetal(metal) self.metal.current = metal return self end - ----@param self TeamBuilder ---@param unitDefID string ----@param unitIdCallback fun(unitID: number)|nil +---@param unitIdCallback fun(unitID: integer)|nil ---@return TeamBuilder function TeamBuilder:WithUnit(unitDefID, unitIdCallback) local rawUnitId = nextUnitId() - local unitID = tonumber(rawUnitId) + local unitID = tonumber(rawUnitId) --[[@as integer?]] if unitID == nil then error(string.format("Generated unit ID '%s' is not numeric", tostring(rawUnitId))) end @@ -162,30 +167,22 @@ function TeamBuilder:WithUnit(unitDefID, unitIdCallback) return self end - ----@param self TeamBuilder ---@return TeamBuilder function TeamBuilder:AI() self.isHuman = false return self end - ----@param self TeamBuilder ---@return TeamBuilder function TeamBuilder:Human() self.isHuman = true return self end - ----@param self TeamBuilder ---@param energy number ---@return TeamBuilder function TeamBuilder:WithEnergy(energy) self.energy.current = energy return self end - ----@param self TeamBuilder ---@param storage number ---@return TeamBuilder function TeamBuilder:WithEnergyStorage(storage) @@ -228,6 +225,11 @@ function TeamBuilder:WithMetalReceived(received) return self end +function TeamBuilder:WithMetalExcess(excess) + self.metal.excess = excess + return self +end + function TeamBuilder:WithEnergyPull(pull) self.energy.pull = pull return self @@ -258,11 +260,16 @@ function TeamBuilder:WithEnergyReceived(received) return self end ----@param self TeamBuilder ----@param playerId number +function TeamBuilder:WithEnergyExcess(excess) + self.energy.excess = excess + return self +end + +---@param playerId integer ---@param opts table|nil ---@return TeamBuilder function TeamBuilder:WithPlayer(playerId, opts) + ---@type PlayerData local player = { id = playerId, name = (opts and opts.name) or self.playerName, @@ -276,23 +283,23 @@ function TeamBuilder:WithPlayer(playerId, opts) playerOpts = (opts and opts.playerOpts) or {}, desynced = (opts and opts.desynced) or false, } - for i, p in ipairs(self.players) do - if p.id == playerId then - self.players[i] = player + local players = self.players or {} + self.players = players + for i, p in ipairs(players) do + if p.id == playerId then players[i] = player return self end end - table.insert(self.players, player) + table.insert(players, player) return self end ----@param self TeamBuilder ----@param playerId number +---@param playerId integer ---@return TeamBuilder function TeamBuilder:WithLeader(playerId) self.leader = playerId local exists = false - for _, p in ipairs(self.players) do + for _, p in ipairs(self.players or {}) do if p.id == playerId then exists = true break @@ -314,4 +321,4 @@ function TeamBuilder:WithUnitFromCategory(category, side) return self:WithUnit(unitDefId) end -return TeamBuilder \ No newline at end of file +return TeamBuilder diff --git a/spec/builders/unit_def_builder.lua b/spec/builders/unit_def_builder.lua index 3494b23c82b..6709adca23b 100644 --- a/spec/builders/unit_def_builder.lua +++ b/spec/builders/unit_def_builder.lua @@ -5,7 +5,7 @@ ---@class UnitDefBuilder ---@field _def table ----@field _defID number|nil +---@field _defID integer|nil local UDB = {} UDB.__index = UDB @@ -25,7 +25,7 @@ function UDB.new(name) }, UDB) end ----@param defID number +---@param defID integer ---@return UnitDefBuilder function UDB:WithDefID(defID) self._defID = defID @@ -63,7 +63,7 @@ function UDB:Builds(...) return self end ----@return number +---@return integer function UDB:GetDefID() if not self._defID then error("UnitDefBuilder for '" .. tostring(self._def.name) .. "' is missing defID; call :WithDefID(n)") diff --git a/spec/builders/unit_defs_builder.lua b/spec/builders/unit_defs_builder.lua index 695148e6823..c85039eda38 100644 --- a/spec/builders/unit_defs_builder.lua +++ b/spec/builders/unit_defs_builder.lua @@ -9,10 +9,10 @@ -- Both views observe the same underlying defs. ---@class UnitDefsBuilder ----@field _byID table +---@field _byID table ---@field _byName table ----@field _names table ----@field _instances table +---@field _names table +---@field _instances table ---@field _realLoaded boolean local UDFB = {} UDFB.__index = UDFB @@ -29,7 +29,7 @@ end ---Register a unit definition. Accepts either a UnitDefBuilder or a (defID, defTable) pair. ---@overload fun(self: UnitDefsBuilder, udb: UnitDefBuilder): UnitDefsBuilder ----@param defID number +---@param defID integer ---@param def table ---@return UnitDefsBuilder function UDFB:WithUnitDef(defID, def) @@ -40,7 +40,7 @@ function UDFB:WithUnitDef(defID, def) resolvedID = udb:GetDefID() resolvedDef = udb:GetDef() else - ---@cast defID number + ---@cast defID integer resolvedID = defID resolvedDef = def end @@ -55,8 +55,8 @@ end ---Register a live unit instance. defIDOrName accepts either a numeric defID ---or the name of a previously-registered def. Errors loudly if the def has ---not been registered (via WithUnitDef or WithRealUnitDefs). ----@param unitID number ----@param defIDOrName number|string +---@param unitID integer +---@param defIDOrName integer|string ---@return UnitDefsBuilder function UDFB:WithUnit(unitID, defIDOrName) local defID @@ -95,7 +95,9 @@ function UDFB:WithRealUnitDefs(loadHarness) local success, defs = pcall(require, "gamedata.unitdefs") if not success or type(defs) ~= "table" then + ---@diagnostic disable-next-line: global-in-non-module _G.UnitDefs = prevDefs + ---@diagnostic disable-next-line: global-in-non-module _G.UnitDefNames = prevNames return end @@ -104,8 +106,8 @@ function UDFB:WithRealUnitDefs(loadHarness) pcall(require, "gamedata.alldefs_post") pcall(require, "gamedata.unitdefs_post") - local loaded = _G.UnitDefs - local names = _G.UnitDefNames + local loaded = _G.UnitDefs ---@type table? + local names = _G.UnitDefNames ---@type table? if type(loaded) == "table" then for _, def in pairs(loaded) do if def.builder ~= nil and def.isBuilder == nil then @@ -144,11 +146,11 @@ function UDFB:GetUnitDefsByID() return self._byID end ---@return table function UDFB:GetUnitDefsByName() return self._byName end ----@return table +---@return table function UDFB:GetUnitDefNames() return self._names end ----@param unitID number ----@return number|nil +---@param unitID integer +---@return integer|nil function UDFB:GetUnitDefID(unitID) return self._instances[unitID] end ---@return boolean diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua index e945ddb39dc..59f4da70bc3 100644 --- a/spec/spec_helper.lua +++ b/spec/spec_helper.lua @@ -31,7 +31,10 @@ _G.Spring = _G.Spring or { if LOG_LEVELS[level] and LOG_LEVELS[level] >= LOG_LEVELS[_G.CURRENT_LOG_LEVEL] then print(string.format("[%s] %s: %s", tag, level, message)) end - end + end, + GetGameFrame = function() + return 0 + end, } _G.Spring.Echo = _G.Spring.Echo or function(...) @@ -40,11 +43,12 @@ end _G.GG = _G.GG or {} -_G.unpack = _G.unpack or table.unpack or function(t, i, j) - i = i or 1; j = j or #t +local function unpackFallback(t, i, j) + i = i or 1 j = j or #t if i > j then return end - return t[i], _G.unpack(t, i+1, j) + return t[i], unpackFallback(t, i+1, j) end +_G.unpack = _G.unpack or table.unpack or unpackFallback -- VFS.Include mock for testing _G.VFS = _G.VFS or {} @@ -60,11 +64,13 @@ _G.VFS.FileExists = function(path) -- Fallback: Case-insensitive check using cached file list if not _G.VFS._ci_file_cache then + ---@type table _G.VFS._ci_file_cache = {} -- Find all files, excluding .git directory - local handle = io.popen("find . -name '.git' -prune -o -type f -print") + local handle = io.popen("find . -name '.git' -prune -o -type f -print") --[[@as any]] if handle then for line in handle:lines() do + ---@cast line string -- Strip leading ./ local p = line:gsub("^%./", "") _G.VFS._ci_file_cache[p:lower()] = p @@ -103,6 +109,7 @@ _G.VFS.Include = function(path, env, mode) end -- Use loadfile/dofile instead of require to better simulate VFS and handle case-insensitive paths + ---@diagnostic disable-next-line: redundant-parameter local chunk, err = loadfile(realPath) if chunk then -- Handle environment if provided @@ -163,7 +170,7 @@ _G.VFS.SubDirs = function(path) local parent = path:match("(.+)/[^/]+$") or "." local base = path:match("([^/]+)$") if base then - local pHandle = io.popen(string.format("find %s -maxdepth 1 -type d -iname '%s'", parent, base)) + local pHandle = io.popen(string.format("find %s -maxdepth 1 -type d -iname '%s'", parent, base)) --[[@as any]] if pHandle then local match = pHandle:read("*l") if match then searchPath = match end @@ -173,7 +180,7 @@ _G.VFS.SubDirs = function(path) end local dirs = {} - local handle = io.popen(string.format("find %s -maxdepth 1 -type d", searchPath)) + local handle = io.popen(string.format("find %s -maxdepth 1 -type d 2>/dev/null", searchPath)) --[[@as any]] if handle then for line in handle:lines() do if line ~= searchPath then @@ -200,7 +207,7 @@ _G.VFS.DirList = function(directory, pattern, mode, recursive) local parent = directory:match("(.+)/[^/]+$") or "." local base = directory:match("([^/]+)$") if base then - local pHandle = io.popen(string.format("find %s -maxdepth 1 -type d -iname '%s'", parent, base)) + local pHandle = io.popen(string.format("find %s -maxdepth 1 -type d -iname '%s'", parent, base)) --[[@as any]] if pHandle then local match = pHandle:read("*l") if match then searchDir = match end @@ -218,7 +225,7 @@ _G.VFS.DirList = function(directory, pattern, mode, recursive) cmd = string.format("find %s %s -maxdepth 1 -type f", searchDir, name_pattern) end - local handle = io.popen(cmd) + local handle = io.popen(cmd) --[[@as any]] if handle then for line in handle:lines() do table.insert(files, line) @@ -229,10 +236,10 @@ _G.VFS.DirList = function(directory, pattern, mode, recursive) return files end -_G.VFS.MAP = 1 -_G.VFS.MOD = 2 -_G.VFS.BASE = 4 -_G.VFS_MODES = _G.VFS.MAP + _G.VFS.MOD + _G.VFS.BASE +_G.VFS.MAP = "m" +_G.VFS.MOD = "M" +_G.VFS.BASE = "b" +_G.VFS_MODES = _G.VFS.MAP .. _G.VFS.MOD .. _G.VFS.BASE -- to enable, `luarocks install inspect` _G.inspect = (function() @@ -241,4 +248,3 @@ _G.inspect = (function() -- fallback: no-op string (won't break prints/concats) return function(_) return _ end end)() -