From 8d5096b2fe11d3b7e54c8f1db844f23ae9ef257f Mon Sep 17 00:00:00 2001 From: G C <37224614+Hdt80bro@users.noreply.github.com> Date: Sat, 16 Nov 2024 00:46:10 -0800 Subject: [PATCH] Fix various issues with the recall mechanic (#6396) --- lua/aibrain.lua | 1 + lua/shared/RecallParams.lua | 2 + lua/sim/Recall.lua | 236 +++++++++++++++++++++--------------- lua/ui/game/recall.lua | 56 +++++++-- 4 files changed, 185 insertions(+), 110 deletions(-) diff --git a/lua/aibrain.lua b/lua/aibrain.lua index 773b37be1b..1427408d7c 100644 --- a/lua/aibrain.lua +++ b/lua/aibrain.lua @@ -436,6 +436,7 @@ AIBrain = Class(FactoryManagerBrainComponent, StatManagerBrainComponent, JammerM import("/lua/simutils.lua").UpdateUnitCap(self:GetArmyIndex()) import("/lua/simping.lua").OnArmyDefeat(self:GetArmyIndex()) + import("/lua/sim/Recall.lua").OnArmyDefeat(self:GetArmyIndex()) local function KillArmy() local shareOption = ScenarioInfo.Options.Share diff --git a/lua/shared/RecallParams.lua b/lua/shared/RecallParams.lua index fac40d18c7..95346192c1 100644 --- a/lua/shared/RecallParams.lua +++ b/lua/shared/RecallParams.lua @@ -14,6 +14,8 @@ TeamVoteCooldown = 1 * 60 * 10 --- ticks that the recall vote is open (30 seconds) VoteTime = 30 * 10 +---@param acceptanceVotes number +---@param totalVotes number function RecallRequestAccepted(acceptanceVotes, totalVotes) if totalVotes <= 3 then return acceptanceVotes >= totalVotes diff --git a/lua/sim/Recall.lua b/lua/sim/Recall.lua index 0af1cca947..6775a7e577 100644 --- a/lua/sim/Recall.lua +++ b/lua/sim/Recall.lua @@ -9,8 +9,8 @@ doscript "/lua/shared/RecallParams.lua" local SyncAnnouncement = import("/lua/simdiplomacy.lua").SyncAnnouncement - ----@alias CannotRecallReason false +---@alias CannotRecallReason +---| false ---| "active" ---| "ai" ---| "gate" @@ -19,7 +19,6 @@ local SyncAnnouncement = import("/lua/simdiplomacy.lua").SyncAnnouncement ---| "vote" ---| "observer" - function init() -- setup sim recall state in the brains local playerCooldown = PlayerGateCooldown - PlayerRequestCooldown @@ -34,42 +33,22 @@ function init() end function OnArmyChange() - local focus = GetFocusArmy() - if focus == -1 then + if GetFocusArmy() == -1 then SyncCancelRecallVote() SyncRecallStatus() - return - end - local teamSize = 0 - local yes, no = 0, 0 - local votingThreadBrain - for index, brain in ArmyBrains do - if IsAlly(focus, index) and not ArmyIsCivilian(index) then - -- Found a voting thread. We really do need a better way to handle team data... - teamSize = teamSize + 1 - if brain.Vote ~= nil then - if brain.Vote then - yes = yes + 1 - else - no = no + 1 - end - end - if brain.recallVotingThread then - votingThreadBrain = brain - end - end + else + ResyncRecallVoting() end - if votingThreadBrain then - Sync.RecallRequest = { - StartTime = votingThreadBrain.RecallVoteStartTime, - Open = VoteTime * 0.1, - Blocks = teamSize, - Yes = yes, - No = no, - CanVote = GetArmyBrain(focus).Vote ~= nil, - } +end + +---@param army integer +function OnArmyDefeat(army) + local focus = GetFocusArmy() + if focus ~= -1 and IsAlly(army, focus) then + -- the rest of the code knows to ignore defeated players, just resync so the + -- UI can update the number of blocks + ResyncRecallVoting() end - SyncRecallStatus() end ---@param data {From: number, To: number} @@ -79,10 +58,7 @@ function OnAllianceChange(data) local oldTeam = {} local votingThreadBrain for index, ally in ArmyBrains do - if (IsAlly(armyFrom, index) or IsAlly(armyTo, index)) - and not ally:IsDefeated() - and not ArmyIsCivilian(index) - then + if (IsAlly(armyFrom, index) or IsAlly(armyTo, index)) and not ArmyIsCivilian(index) then oldTeamSize = oldTeamSize + 1 oldTeam[oldTeamSize] = ally.Nickname -- Found a voting thread. We really do need a better way to handle team data... @@ -93,7 +69,7 @@ function OnAllianceChange(data) end if votingThreadBrain then SPEW("Canceling recall voting for team " .. table.concat(oldTeam, ", ") .. " due to alliance break") - votingThreadBrain.VoteCancelled = true + votingThreadBrain.RecallVoteCancelled = true ResumeThread(votingThreadBrain.recallVotingThread) if IsAlly(votingThreadBrain, GetFocusArmy()) then SyncCancelRecallVote() @@ -103,12 +79,13 @@ function OnAllianceChange(data) end + ---@param lastTeamVote number ---@param lastPlayerRequest number ---@param playerGatein? number ---@return CannotRecallReason CannotRecallReason ---@return number? cooldown -function RecallRequestCooldown(lastTeamVote, lastPlayerRequest, playerGatein) +local function RecallRequestCooldown(lastTeamVote, lastPlayerRequest, playerGatein) -- note that this doesn't always return the reason that currently has the longest cooldown, it -- returns the more "fundamental" one (i.e. the reason whose base cooldown is longest) -- this is more useful in reporting the reason, and isn't a problem as the reason checker is a loop @@ -134,10 +111,10 @@ end ---@return CannotRecallReason ---@return number? cooldown no timeout/cooldown if absent function ArmyRecallRequestCooldown(army) - if army == -1 then + local brain = GetArmyBrain(army) + if army == -1 or brain:IsDefeated() then return "observer" end - local brain = GetArmyBrain(army) if ScenarioInfo.RecallDisabled then return "scenario" end @@ -172,12 +149,12 @@ local function RecallVotingThread(requestingArmy) WaitTicks(VoteTime) -- may be interrupted if the vote closes or is canceled by an alliance break local focus = GetFocusArmy() - if requestingBrain.VoteCancelled then + if requestingBrain.RecallVoteCancelled then if focus ~= -1 and IsAlly(requestingArmy, focus) then SyncCancelRecallVote() SyncRecallStatus() end - requestingBrain.VoteCancelled = nil + requestingBrain.RecallVoteCancelled = nil requestingBrain.RecallVoteStartTime = nil requestingBrain.recallVotingThread = nil return @@ -185,19 +162,29 @@ local function RecallVotingThread(requestingArmy) local gametick = GetGameTick() local yesVotes = 0 + local noVotes = 0 local teamSize = 0 local team = {} for index, brain in ArmyBrains do - if not brain:IsDefeated() and IsAlly(requestingArmy, brain.Army) and not ArmyIsCivilian(index) then + if not IsAlly(requestingArmy, brain.Army) or ArmyIsCivilian(index) then + continue + end + + if not brain:IsDefeated() then teamSize = teamSize + 1 team[teamSize] = brain - if brain.RecallVote then - yesVotes = yesVotes + 1 + if brain.RecallVote ~= nil then + if brain.RecallVote then + yesVotes = yesVotes + 1 + else + noVotes = noVotes + 1 + end end - brain.RecallVote = nil brain.LastRecallVoteTime = gametick end + brain.RecallVote = nil -- make sure defeated players get reset too end + -- this function is found in the recall params file, for those looking local recallPassed = RecallRequestAccepted(yesVotes, teamSize) if focus ~= -1 and IsAlly(focus, requestingArmy) then @@ -210,19 +197,22 @@ local function RecallVotingThread(requestingArmy) Team = requestingBrain.Nickname, } end + local listTeam = team[1].Nickname for i = 2, teamSize do listTeam = listTeam .. ", " .. team[i].Nickname end + local msgEnding = yesVotes .. " to " .. noVotes .. " [" .. (teamSize - yesVotes - noVotes) .. " abstained] )" if recallPassed then - SPEW("Recalling team " .. listTeam .. " at the request of " .. requestingBrain.Nickname .. " (vote passed " .. yesVotes .. " to " .. (teamSize - yesVotes ) .. ")") + SPEW("Recalling team " .. listTeam .. " at the request of " .. requestingBrain.Nickname .. " (vote passed " .. msgEnding) for _, brain in team do brain:RecallAllCommanders() end else - SPEW("Not recalling team " .. listTeam .. " (vote failed " .. yesVotes .. " to " .. (teamSize - yesVotes ) .. ")") + SPEW("Not recalling team " .. listTeam .. " (vote failed " .. msgEnding) requestingBrain.LastRecallRequestTime = gametick end + if focus ~= -1 and IsAlly(requestingArmy, focus) then -- update UI once the cooldown dissipates SyncRecallStatus() @@ -340,7 +330,7 @@ function SetRecallVote(data) return end if teammates > 0 then - SPEW("Recall request from " .. brain.Nickname .. " for " .. table.concat(team, ',')) + SPEW("Recall request from " .. brain.Nickname .. " for " .. table.concat(team, ", ")) else SPEW("Recalling " .. brain.Nickname) end @@ -353,95 +343,137 @@ function SetRecallVote(data) brain.RecallVote = vote -- if the vote will already be decided with this vote, close the voting session - if not lastVote and ( - vote and RecallRequestAccepted(likeVotes + 1, teammates) or -- will succeed with our vote - not vote and not RecallRequestAccepted(teammates - (likeVotes + 1), teammates) -- won't ever be able to succeed - ) then - lastVote = true + if not lastVote then + if vote then + -- will succeed with our vote + lastVote = RecallRequestAccepted(likeVotes + 1, teammates + 1) + else + -- won't ever be able to succeed + -- teammates - votes against = teammates that could vote for recall + lastVote = not RecallRequestAccepted(teammates + 1 - (likeVotes + 1), teammates + 1) + end end ArmyVoteRecall(army, vote, lastVote) end end +-------------------- +--#region Sync +-------------------- + +local function GetRecallSyncTable() + local sync = Sync.RecallRequest + if not sync then + sync = {} + Sync.RecallRequest = sync + end + return sync +end + +function ResyncRecallVoting() + local focus = GetFocusArmy() + local teamSize = 0 + local yes, no = 0, 0 + local votingThreadBrain + local retainBlocks = false + for index, brain in ArmyBrains do + if IsAlly(focus, index) and not ArmyIsCivilian(index) then + -- Found a voting thread. We really do need a better way to handle team data... + if brain.recallVotingThread then + votingThreadBrain = brain + if brain:IsDefeated() then + retainBlocks = true + end + end + -- it's possible a defeated player could have been the one to initiate the vote but + -- they don't count for votes + if brain:IsDefeated() then + continue + end + teamSize = teamSize + 1 + if brain.RecallVote ~= nil then + if brain.RecallVote then + yes = yes + 1 + else + no = no + 1 + end + end + end + end + if votingThreadBrain then + -- keep the block layout in the edge-case that there are 3 (or more) players + -- and the original requester is defeated so there are only 2 players - both + -- could still need to vote so the confirmation layout is inappropriate + if teamSize <= 2 and not retainBlocks then + teamSize = nil + end + + local focusBrain = GetArmyBrain(focus) + + -- no need to add changes from `GetRecallSyncTable`, we need to reset everything anyway + Sync.RecallRequest = { + StartTime = votingThreadBrain.RecallVoteStartTime * 0.1, -- convert ticks to seconds + Open = VoteTime * 0.1, -- convert ticks to seconds + Blocks = teamSize, + Yes = yes, + No = no, + CanVote = focusBrain.RecallVote == nil and not focusBrain:IsDefeated(), + } + end + SyncRecallStatus() +end + ---@param reason CannotRecallReason function SyncCannotRequestRecall(reason) - local recallSync = Sync.RecallRequest - if not recallSync then - Sync.RecallRequest = {CannotRequest = reason} - else - recallSync.CannotRequest = reason - end + GetRecallSyncTable().CannotRequest = reason end ---@param result boolean function SyncCloseRecallVote(result) - local recallSync = Sync.RecallRequest - if not recallSync then - Sync.RecallRequest = {Close = result} - else - recallSync.Close = result - end + GetRecallSyncTable().Close = result end function SyncCancelRecallVote() - local recallSync = Sync.RecallRequest - if not recallSync then - Sync.RecallRequest = {Cancel = true} - else - recallSync.Cancel = true - end + GetRecallSyncTable().Cancel = true end ---@param vote boolean function SyncRecallVote(vote) - local recallSync = Sync.RecallRequest - if not recallSync then - recallSync = {} - Sync.RecallRequest = recallSync - end + local sync = GetRecallSyncTable() if vote then - recallSync.Yes = (recallSync.Yes or 0) + 1 + sync.Yes = (sync.Yes or 0) + 1 else - recallSync.No = (recallSync.No or 0) + 1 + sync.No = (sync.No or 0) + 1 end end ---@param teamSize number ---@param army number function SyncOpenRecallVote(teamSize, army) - local recallSync = Sync.RecallRequest - if not recallSync then - recallSync = {} - Sync.RecallRequest = recallSync - end + local sync = GetRecallSyncTable() local focus = GetFocusArmy() - recallSync.Open = VoteTime * 0.1 - recallSync.CanVote = focus ~= -1 and army ~= focus - recallSync.Blocks = teamSize + sync.Open = VoteTime * 0.1 -- convert ticks to seconds + sync.CanVote = focus ~= -1 and army ~= focus and not GetArmyBrain(focus):IsDefeated() + if teamSize > 2 then + sync.Blocks = teamSize + end end local UserRecallStatusThread local function SyncRecallStatusThread() local reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy()) - while reason do + while cooldown do SyncCannotRequestRecall(reason) - if not cooldown then - UserRecallStatusThread = nil - return - end + -- may be interrupted for various reasons, such as the focus army changing -- this will be fine, we'll pick up the proper cooldown reason anyway and loop again - if cooldown < 1 then - WaitTicks(1) - else - WaitTicks(cooldown) - end + WaitTicks(math.max(1, cooldown)) reason, cooldown = ArmyRecallRequestCooldown(GetFocusArmy()) end - SyncCannotRequestRecall(false) + SyncCannotRequestRecall(reason) UserRecallStatusThread = nil end @@ -452,3 +484,5 @@ function SyncRecallStatus() UserRecallStatusThread = ForkThread(SyncRecallStatusThread) end end + +--#endregion diff --git a/lua/ui/game/recall.lua b/lua/ui/game/recall.lua index 1cc5ed0c5d..03585d71d3 100644 --- a/lua/ui/game/recall.lua +++ b/lua/ui/game/recall.lua @@ -66,6 +66,18 @@ function ToggleControl() end end +---@class RecallSyncData +---@field StartTime number # When the recall vote started sim-side in seconds +---@field Open number # Duration of the recall vote in seconds +---@field Blocks number? # number of voters. `nil` if the block count does not need to be updated, due to the edge case of a 2 player team where neither player has voted due to the vote requester's defeat +---@field Yes number # number of Yes votes +---@field No number # number of No votes +---@field CanVote boolean # Whether or not the focused army has voted or is defeated +---@field Cancel true? +---@field CannotRequest CannotRecallReason +---@field Close boolean # boolean is the recall result + +---@param data RecallSyncData function RequestHandler(data) if data.CannotRequest ~= nil then import("/lua/ui/game/diplomacy.lua").SetCannotRequestRecallReason(data.CannotRequest) @@ -168,15 +180,15 @@ RecallPanel = ClassUI(NinePatch.NinePatch) { local currentBlocks = votes.blocks if blocks ~= currentBlocks then votes.blocks = blocks - for i = currentBlocks, 1, -1 do + for i = (currentBlocks or 1), 1, -1 do local block = votes[i] if block then block:Destroy() end votes[i] = nil end - if blocks > 2 then - local panelWidth = votes.Width() + if blocks then + local panelWidth = votes.Width() / LayoutHelpers.GetPixelScaleFactor() -- pre-unmultiply scale factor local width = math.floor(panelWidth / blocks) local offsetX = math.floor((panelWidth - blocks * width) * 0.5) - width for i = 1, blocks do @@ -200,6 +212,18 @@ RecallPanel = ClassUI(NinePatch.NinePatch) { end -- manual dirtying of the lazyvar votes.Height[1] = nil + elseif currentBlocks then + local function SetTextures(vote, filename) + vote._left:SetTexture(UIUtil.UIFile(filename .. "_bmp_l.dds")) + vote._middle:SetTexture(UIUtil.UIFile(filename .. "_bmp_m.dds")) + vote._right:SetTexture(UIUtil.UIFile(filename .. "_bmp_r.dds")) + end + + -- reset the status of existing blocks + for i = 1, currentBlocks do + votes[i].cast = nil + SetTextures(votes[i], "/game/recall-panel/recall-vote") + end end end, @@ -306,6 +330,7 @@ RecallPanel = ClassUI(NinePatch.NinePatch) { self.reviewResultsThread = nil end, self) end + self.label:SetText(LOC("Ready for recall")) end, CancelVote = function(self) @@ -323,7 +348,7 @@ RecallPanel = ClassUI(NinePatch.NinePatch) { self.startTime:Set(-9999) -- make sure the OnFrame animation ends if self.reviewResultsThread then -- continue the OnSecond animation if it exists - coroutine.resume(self.reviewResultsThread) + ResumeThread(self.reviewResultsThread) else -- otherwise, create our own result reviewing handler self.reviewResultsThread = ForkThread(function(self) @@ -342,12 +367,15 @@ RecallPanel = ClassUI(NinePatch.NinePatch) { AddVotes = function(self, yes, no) local votes = self.votes - if votes.blocks < 3 then return end + if not votes.blocks then return end + local function SetTextures(vote, filename) vote._left:SetTexture(UIUtil.UIFile(filename .. "_bmp_l.dds")) vote._middle:SetTexture(UIUtil.UIFile(filename .. "_bmp_m.dds")) vote._right:SetTexture(UIUtil.UIFile(filename .. "_bmp_r.dds")) end + + -- get where these new votes should be added on top of existing ones local index = 1 for i = 1, votes.blocks do if not votes[i].cast then @@ -355,6 +383,8 @@ RecallPanel = ClassUI(NinePatch.NinePatch) { break end end + + -- add the new votes if yes then for _ = 1, yes do local vote = votes[index] @@ -403,13 +433,21 @@ RecallPanel = ClassUI(NinePatch.NinePatch) { if time > 0 then local dur = self.duration time = GetGameTimeSeconds() - time - local nominalWidth = self.Width() - LayoutHelpers.ScaleNumber(16) + local pb = self.progressBar + local bg = self.progressBarBG + local nominalWidth = bg.Width() - LayoutHelpers.ScaleNumber(16) if time >= dur then self.startTime:Set(-9999) - self.progressBar.Width:Set(0) - self.progressBar:Hide() + LayoutHelpers.AtHorizontalCenterIn(bg, pb) + pb.Width:Set(0) + pb:Hide() else - self.progressBar.Width:Set((1 - time / dur) * nominalWidth) + local wings = 0.5 * (1 - time / dur) * nominalWidth + -- it jitters less when you set both the left and the right instead of relying + -- on the layout centering with the width + local center = bg.Left() + 0.5 * bg.Width() + pb.Left:Set(math.floor(center - wings)) + pb.Right:Set(math.ceil(center + wings)) end notAnimating = false end