Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions common/luaUtilities/economy/share_stats.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- Lua-side resource-sharing stats (sent / received).
--
-- The engine keeps the `excess` stat: gadget:ResourceExcess legitimately breaks the
-- engine's conservation ledger (the resource leaves the system), so a callout to record
-- it is fine. sent/received are conserved internal transfers, so -- per sprunk's steer on
-- RecoilEngine#3032 -- we track those in Lua instead of adding engine API for them.
--
-- The waterfill solver yields *net per-team* flows, which is exactly the shape every
-- consumer wants (top bar, team stats): nobody downstream needs the pairwise
-- who-sent-to-whom, so there is nothing to "unpack" -- the per-team totals map straight
-- through.
--
-- Transport is team rules params (synced controller writes, unsynced widgets read).
-- Accumulation is read-modify-write on the params themselves, so there is no module-local
-- state to lose across a luarules reload.

local ResourceTypes = VFS.Include("gamedata/resource_types.lua")
local METAL = ResourceTypes.METAL

local ShareStats = {}

local function suffix(resourceType)
return resourceType == METAL and "m" or "e"
end

-- per-team rules-param keys: cumulative lifetime sent/received, plus the last tick's send
-- (the top bar's overflow indicator used to read this from the engine's per-period resSent)
local function cumSentKey(rt) return "sharestat_" .. suffix(rt) .. "_sent" end
local function cumRecvKey(rt) return "sharestat_" .. suffix(rt) .. "_received" end
local function recentSentKey(rt) return "sharestat_" .. suffix(rt) .. "_sent_recent" end
local function recentRecvKey(rt) return "sharestat_" .. suffix(rt) .. "_received_recent" end

ShareStats.cumSentKey = cumSentKey
ShareStats.cumRecvKey = cumRecvKey
ShareStats.recentSentKey = recentSentKey
ShareStats.recentRecvKey = recentRecvKey

-- allies (and spectators) can read; matches the visibility of the engine stats it replaces
local RULES_ACCESS = { allied = true }

---Record one cadence tick of solver results into the per-team rules params.
---@param springRepo SpringSynced
---@param results EconomyTeamResult[]
function ShareStats.Publish(springRepo, results)
for i = 1, #results do
local r = results[i]
local rt = r.resourceType
local sent = (springRepo.GetTeamRulesParam(r.teamId, cumSentKey(rt)) or 0) + (r.sent or 0)
local received = (springRepo.GetTeamRulesParam(r.teamId, cumRecvKey(rt)) or 0) + (r.received or 0)
springRepo.SetTeamRulesParam(r.teamId, cumSentKey(rt), sent, RULES_ACCESS)
springRepo.SetTeamRulesParam(r.teamId, cumRecvKey(rt), received, RULES_ACCESS)
springRepo.SetTeamRulesParam(r.teamId, recentSentKey(rt), r.sent or 0, RULES_ACCESS)
springRepo.SetTeamRulesParam(r.teamId, recentRecvKey(rt), r.received or 0, RULES_ACCESS)
end
end

---Read a team's sharing stats for one resource. Fields are nil when no Lua stats have
---been published (e.g. vanilla / native sharing), letting callers fall back to engine values.
---@param springApi table Spring (or a synced-repo) exposing GetTeamRulesParam
---@param teamID number
---@param resourceType ResourceName
---@return { sent: number?, received: number?, sentRecent: number?, receivedRecent: number? }
function ShareStats.Read(springApi, teamID, resourceType)
return {
sent = springApi.GetTeamRulesParam(teamID, cumSentKey(resourceType)),
received = springApi.GetTeamRulesParam(teamID, cumRecvKey(resourceType)),
sentRecent = springApi.GetTeamRulesParam(teamID, recentSentKey(resourceType)),
receivedRecent = springApi.GetTeamRulesParam(teamID, recentRecvKey(resourceType)),
}
end

return ShareStats
68 changes: 68 additions & 0 deletions common/luaUtilities/team_transfer/gui_advplayerlist/validation.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
--- Unit validation helpers for advplayerslist.lua
local UnitShared = VFS.Include("common/luaUtilities/team_transfer/unit_transfer_shared.lua")

local UNIT_VALIDATION_PREFIX = "unit_validation_"

local UnitValidationFields = {
status = true,
invalidUnitCount = true,
invalidUnitIds = true,
invalidUnitNames = true,
validUnitCount = true,
validUnitIds = true,
validUnitNames = true,
buildDelayedUnitCount = true,
stunnedUnitCount = true,
}

local validationResultScratch = {}

local UnitValidationHelpers = {}

---@param validationResult UnitValidationResult
---@param playerData table
function UnitValidationHelpers.PackSelectedUnitsValidation(validationResult, playerData)
for field, _ in pairs(UnitValidationFields) do
playerData[UNIT_VALIDATION_PREFIX .. field] = validationResult and validationResult[field] or nil
end
end

---@param playerData table
function UnitValidationHelpers.ClearSelectedUnitsValidation(playerData)
for field, _ in pairs(UnitValidationFields) do
playerData[UNIT_VALIDATION_PREFIX .. field] = nil
end
end

---@param playerData table
---@return UnitValidationResult | nil
function UnitValidationHelpers.UnpackSelectedUnitsValidation(playerData)
if playerData[UNIT_VALIDATION_PREFIX .. "status"] == nil then
return nil
end
local scratch = validationResultScratch
for field, _ in pairs(UnitValidationFields) do
scratch[field] = playerData[UNIT_VALIDATION_PREFIX .. field]
end
return scratch
end

---@param player table
---@param myTeamID number
---@param selectedUnits number[]
function UnitValidationHelpers.UpdatePlayerUnitValidations(player, myTeamID, selectedUnits)
for _, playerData in pairs(player) do
if playerData.team and playerData.team ~= myTeamID then
if selectedUnits and #selectedUnits > 0 then
local policyResult = UnitShared.GetCachedPolicyResult(myTeamID, playerData.team, Spring)
local validationResult = UnitShared.ValidateUnits(policyResult, selectedUnits, Spring)
UnitValidationHelpers.PackSelectedUnitsValidation(validationResult, playerData)
else
UnitValidationHelpers.ClearSelectedUnitsValidation(playerData)
end
end
end
end

return UnitValidationHelpers

28 changes: 26 additions & 2 deletions common/luaUtilities/team_transfer/policy_events.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
-- Emits a generic "SharePolicyChanged" event when a team's cached sharing policy changes,
-- forwarded to widgets via game_share_policy_forwarding.lua -> widget:SharePolicyChanged.
-- Synced-side surface for sharing-policy events forwarded to widgets via
-- game_share_policy_forwarding.lua. Covers team policy changes (SharePolicyChanged)
-- and the per-unit build-delay debuff that the constructor-build-delay policy applies.
local PolicyEvents = {}

PolicyEvents.Domain = {
Expand Down Expand Up @@ -42,4 +43,27 @@ function PolicyEvents.NotifyIfChanged(teamId, domain, signature, sendToUnsynced)
return true
end

---Per-unit manifestation of the constructor-build-delay policy: a builder gained the
---buildspeed debuff for [startFrame, expireFrame).
---@param unitID number
---@param startFrame number
---@param expireFrame number
---@param sendToUnsynced function? defaults to the synced SendToUnsynced global (injectable for tests)
function PolicyEvents.NotifyBuildDelay(unitID, startFrame, expireFrame, sendToUnsynced)
local send = sendToUnsynced or SendToUnsynced
if send then
send("UnitBuildDelayStarted", unitID, startFrame, expireFrame)
end
end

---The build-delay debuff on a unit ended (expired or unit gone).
---@param unitID number
---@param sendToUnsynced function? defaults to the synced SendToUnsynced global (injectable for tests)
function PolicyEvents.NotifyBuildDelayEnd(unitID, sendToUnsynced)
local send = sendToUnsynced or SendToUnsynced
if send then
send("UnitBuildDelayEnded", unitID)
end
end

return PolicyEvents
17 changes: 17 additions & 0 deletions common/luaUtilities/team_transfer/take_comms.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ function Comms.CategoryDisplayName(category)
return categoryDisplayNames[category] or category
end

---@class TakePolicy
---@field mode string TakeMode enum value
---@field delaySeconds number
---@field delayCategory string UnitCategory enum value

---Resolve the global take policy from modoptions. Single source of truth so the take
---action and any consumer read the policy object rather than raw modoptions.
---@param modOptions table
---@return TakePolicy
function Comms.GetPolicy(modOptions)
return {
mode = modOptions[ModeEnums.ModOptions.TakeMode] or ModeEnums.TakeMode.Enabled,
delaySeconds = tonumber(modOptions[ModeEnums.ModOptions.TakeDelaySeconds]) or 30,
delayCategory = modOptions[ModeEnums.ModOptions.TakeDelayCategory] or ModeEnums.UnitCategory.Resource,
}
end

---@class TakeResult
---@field mode string TakeMode enum value
---@field takerName string Name of the player who issued /take
Expand Down
14 changes: 14 additions & 0 deletions common/luaUtilities/team_transfer/unit_sharing_categories.lua
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ function sharing.isConstructorDef(unitDef)
return unitDef.isBuilder == true or unitDef.canAssist == true
end

---Mobile builders that receive the constructor build delay when shared. Stricter than
---isConstructorDef: immobile nano/con turrets are excluded (they cannot move) and the unit
---must actually be a builder; fast T2 engineers are included since they are mobile builders.
---MUST match the affected set applied in game_unit_transfer_controller so the share
---tooltip's prediction equals what actually happens.
---@param unitDef table
---@return boolean
function sharing.isMobileBuilderDef(unitDef)
return unitDef ~= nil
and unitDef.isBuilder == true
and not unitDef.isImmobile
and not unitDef.isFactory
end

---Unit-producing factories.
---@param unitDef table
---@return boolean
Expand Down
39 changes: 37 additions & 2 deletions common/luaUtilities/team_transfer/unit_transfer_comms.lua
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,41 @@ function Comms.DecideCommunicationCase(policy, validationResult)
end
end

---Append share-time effect notes for the current selection. Unit-aware: each note is
---driven by the validation's affected count, so it only shows (and counts) when units that
---the effect applies to are actually being shared. Covers the constructor build delay and
---the stun-category stun.
---@param text string
---@param policy UnitPolicyResult
---@param validationResult UnitValidationResult?
---@return string
local function withPolicyEffects(text, policy, validationResult)
if not validationResult then
return text
end

local buildDelay = tonumber(policy.buildDelaySeconds) or 0
local builderCount = tonumber(validationResult.buildDelayedUnitCount) or 0
if buildDelay > 0 and builderCount > 0 then
text = text .. " " .. Spring.I18N('ui.playersList.shareUnits.base.buildDelay', {
count = builderCount,
buildDelaySeconds = buildDelay,
})
end

local stunSeconds = tonumber(policy.stunSeconds) or 0
local stunnedCount = tonumber(validationResult.stunnedUnitCount) or 0
if stunSeconds > 0 and stunnedCount > 0 then
text = text .. " " .. Spring.I18N('ui.playersList.shareUnits.base.stunDelay', {
count = stunnedCount,
stunSeconds = stunSeconds,
stunCategory = policy.stunCategory and Spring.I18N('ui.unitSharingMode.' .. policy.stunCategory) or "",
})
end

return text
end

---@param policy UnitPolicyResult
---@param validationResult UnitValidationResult?
function Comms.TooltipText(policy, validationResult)
Expand Down Expand Up @@ -130,14 +165,14 @@ function Comms.TooltipText(policy, validationResult)
local td = techI18nData(policy)
for k, v in pairs(td) do i18nData[k] = v end
end
return Spring.I18N(u .. '.invalid', i18nData)
return withPolicyEffects(Spring.I18N(u .. '.invalid', i18nData), policy, validationResult)

elseif case == TransferEnums.UnitCommunicationCase.OnFullyShareable then
local i18nData = {}
if validationResult then
i18nData.validUnitCount = validationResult.validUnitCount
end
return Spring.I18N('ui.playersList.shareUnits.base.default', i18nData)
return withPolicyEffects(Spring.I18N('ui.playersList.shareUnits.base.default', i18nData), policy, validationResult)
else
error('Invalid unit communication case: ' .. case)
end
Expand Down
19 changes: 17 additions & 2 deletions common/luaUtilities/team_transfer/unit_transfer_shared.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ local FieldTypes = PolicyShared.FieldTypes
Shared.UnitPolicyFields = {
canShare = FieldTypes.boolean,
sharingModes = FieldTypes.string,
-- carried to the widget so the share tooltip can surface the share-time effects
buildDelaySeconds = FieldTypes.number,
stunSeconds = FieldTypes.number,
stunCategory = FieldTypes.string,
}

-- Per-team unit policy factors. The unit policy is separable: canShare =
Expand Down Expand Up @@ -77,6 +81,8 @@ function Shared.ValidateUnits(policyResult, unitIds, springApi, unitDefs)
invalidUnitCount = 0,
invalidUnitNames = {},
invalidUnitIds = {},
buildDelayedUnitCount = 0, -- valid units that will receive the constructor build delay
stunnedUnitCount = 0, -- valid units that will be stunned (stun category)
}

if (not policyResult.canShare) or (not unitIds or #unitIds == 0) then
Expand Down Expand Up @@ -115,6 +121,12 @@ function Shared.ValidateUnits(policyResult, unitIds, springApi, unitDefs)
if ok then
out.validUnitCount = out.validUnitCount + 1
table.insert(out.validUnitIds, unitId)
if UnitSharingCategories.isMobileBuilderDef(def) then
out.buildDelayedUnitCount = out.buildDelayedUnitCount + 1
end
if wouldBeStunned(unitDefID, stunCategory, defs) then
out.stunnedUnitCount = out.stunnedUnitCount + 1
end
if not validUnitNamesSet[unitName] then
validUnitNamesSet[unitName] = true
table.insert(out.validUnitNames, unitName)
Expand Down Expand Up @@ -143,8 +155,8 @@ end

---Reconstruct the (sender,receiver) unit policy from cached per-team factors plus live
---gates. Mirrors Synced.GetPolicy: canShare = areAllied AND modeNotNone(sender); cheating
---bypasses only the active check (alliance is still required). Stun config is always read
---live from modoptions. Missing factors fall back to the global unit_sharing_mode.
---bypasses only the active check (alliance is still required). Stun and build-delay config
---is always read live from modoptions. Missing factors fall back to the global unit_sharing_mode.
---@param senderTeamId number
---@param receiverTeamId number
---@param springApi SpringSynced?
Expand All @@ -154,6 +166,7 @@ function Shared.GetCachedPolicyResult(senderTeamId, receiverTeamId, springApi)
local modOptions = spring.GetModOptions()
local stunSeconds = tonumber(modOptions[ModeEnums.ModOptions.UnitShareStunSeconds]) or 0
local stunCategory = modOptions[ModeEnums.ModOptions.UnitStunCategory] or ModeEnums.UnitFilterCategory.Resource
local buildDelaySeconds = tonumber(modOptions[ModeEnums.ModOptions.ConstructorBuildDelay]) or 0

local areAllied = (spring.AreTeamsAllied and spring.AreTeamsAllied(senderTeamId, receiverTeamId)) == true

Expand All @@ -172,6 +185,7 @@ function Shared.GetCachedPolicyResult(senderTeamId, receiverTeamId, springApi)
sharingModes = { category },
stunSeconds = stunSeconds,
stunCategory = stunCategory,
buildDelaySeconds = buildDelaySeconds,
}
end

Expand All @@ -195,6 +209,7 @@ function Shared.GetCachedPolicyResult(senderTeamId, receiverTeamId, springApi)
sharingModes = modes,
stunSeconds = stunSeconds,
stunCategory = stunCategory,
buildDelaySeconds = buildDelaySeconds,
}
end

Expand Down
2 changes: 2 additions & 0 deletions common/luaUtilities/team_transfer/unit_transfer_synced.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ function Synced.GetPolicy(ctx)
end
local stunSeconds = tonumber(modOptions[ModeEnums.ModOptions.UnitShareStunSeconds]) or 0
local stunCategory = modOptions[ModeEnums.ModOptions.UnitStunCategory] or ModeEnums.UnitFilterCategory.Resource
local buildDelaySeconds = tonumber(modOptions[ModeEnums.ModOptions.ConstructorBuildDelay]) or 0
return {
canShare = canShare,
senderTeamId = ctx.senderTeamId,
receiverTeamId = ctx.receiverTeamId,
sharingModes = modes,
stunSeconds = stunSeconds,
stunCategory = stunCategory,
buildDelaySeconds = buildDelaySeconds,
techBlocking = ctx.ext and ctx.ext.techBlocking or nil,
}
end
Expand Down
6 changes: 3 additions & 3 deletions gamedata/modrules.lua
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ local modrules = {
},

system = {
allowTake = true, -- Enables and disables the /take UI command.
allowTake = false, -- Engine /take is disabled; the Lua take system (cmd_take) owns /take so it falls through to LuaUI.
LuaAllocLimit = 1536, -- default: 1536. Global Lua alloc limit (in megabytes)
enableSmoothMesh = true,

pathFinderSystem = useQTPFS and 1 or 0, -- Which pathfinder does the game use? Can be 0 - The legacy default pathfinder, 1 - Quad-Tree Pathfinder System (QTPFS) or -1 - disabled.
--pathFinderUpdateRate = 0.0001, -- default: 0.007. Controls how often the pathfinder updates; larger values means more rapid updates
pathFinderRawDistMult = 100000, -- default: 1.25. Engine does raw move with a limited distance, this multiplier adjusts that
Expand All @@ -102,7 +102,7 @@ local modrules = {
pfUpdateRateScale = 1, -- default: 1. Multiplier for the update rate
pfRawMoveSpeedThreshold = 0, -- default: 0. Controls the speed modifier (which includes typemap boosts and up/down hill modifiers) under which units will never do raw move, regardless of distance etc. Defaults to 0, which means units will not try to raw-move into unpathable terrain (e.g. typemapped lava, cliffs, water). You can set it to some positive value to make them avoid pathable but very slow terrain (for example if you set it to 0.2 then they will not raw-move across terrain where they move at 20% speed or less, and will use normal pathing instead - which may still end up taking them through that path).
pfHcostMult = 0.2, -- default: 0.2. A float value between 0 and 2. Controls how aggressively the pathing search prioritizes nodes going in the direction of the goal. Higher values mean pathing is cheaper, but can start producing degenerate paths where the unit goes straight at the goal and then has to hug a wall.
nativeExcessSharing = Spring.GetModOptions().easytax==false and Spring.GetModOptions().tax_resource_sharing_amount==0, -- default: true. If true, the engine will handle resource overflow sharing between allied teams. If false, overflow sharing is disabled and we use Lua implementation in game_tax_resource_sharing.lua gadget.
nativeExcessSharing = false, -- default: true. If true, the engine will handle resource overflow sharing between allied teams. If false, overflow sharing is disabled and we use Lua implementation in game_tax_resource_sharing.lua gadget.
},

transportability = {
Expand Down
Loading
Loading