From bb3a6606bb7cbccb08fec02bd9c300a0457483bc Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 09:18:48 +0200 Subject: [PATCH 01/14] Implement SW.GroupAs and SW.GroupAs.SyncLongestCooldown shared cooldown - Add SW.GroupAs list of group names to [SuperWeaponType] - Add SW.GroupAs.SyncLongestCooldown boolean tag (default false) - Reset active superweapons sharing a group with the fired superweapon - Optionally synchronize all group members to the longest recharge time of active superweapons - Document new tags in docs/New-or-Enhanced-Logics.md --- docs/New-or-Enhanced-Logics.md | 14 ++++++ src/Ext/SWType/Body.cpp | 5 +++ src/Ext/SWType/Body.h | 7 +++ src/Ext/SWType/FireSuperWeapon.cpp | 70 ++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+) diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index aae20369cb..a75e5ba3fb 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -1447,6 +1447,20 @@ Message.LinkedSWAcquired= ; CSF entry key EVA.LinkedSWAcquired= ; EVA entry ``` +### Shared Cooldown / Group Superweapons + +- Superweapons can be grouped together using `SW.GroupAs`. When any superweapon belonging to a group is launched, all other currently active (`IsPresent`) superweapons belonging to that group for the firer house will have their countdown timers reset (`RechargeTimer`), implementing a shared cooldown mechanism. +- `SW.GroupAs` accepts a comma-separated list of group names, allowing a superweapon to belong to multiple groups simultaneously. + - The reset is non-transitive: launching a superweapon only resets weapons that share at least one group with the *fired* weapon. Weapons reset collaterally do not propagate resets to other groups. +- `SW.GroupAs.SyncLongestCooldown` controls whether all present superweapons in the affected group(s) synchronize their cooldown to the longest `RechargeTime` among the superweapons currently active (built) in the group for that player. If set to `false`, each superweapon resets to its own individual recharge time. + +In `rulesmd.ini`: +```ini +[SOMESW] ; SuperWeaponType +SW.GroupAs= ; List of group names (strings separated by commas) +SW.GroupAs.SyncLongestCooldown=false ; boolean +``` + ### Next ![image](_static/images/swnext.gif) diff --git a/src/Ext/SWType/Body.cpp b/src/Ext/SWType/Body.cpp index 531a66fb0d..7d714cf82b 100644 --- a/src/Ext/SWType/Body.cpp +++ b/src/Ext/SWType/Body.cpp @@ -106,6 +106,8 @@ void SWTypeExt::Serialize(T& Stm) .Process(this->EVA_Activated_Owner) .Process(this->EVA_Activated_Allies) .Process(this->EVA_Activated_Enemies) + .Process(this->SW_GroupAs) + .Process(this->SW_GroupAs_SyncLongestCooldown) ; } @@ -242,6 +244,9 @@ void SWTypeExt::LoadFromINIFile(CCINIClass* const pINI) this->EVA_LinkedSWAcquired.Read(exINI, pSection, "EVA.LinkedSWAcquired"); this->SW_Link_RollChances.Read(exINI, pSection, "SW.Link.RollChances"); + exINI.ParseStringList(this->SW_GroupAs, pSection, "SW.GroupAs"); + this->SW_GroupAs_SyncLongestCooldown.Read(exINI, pSection, "SW.GroupAs.SyncLongestCooldown"); + this->Message_Activated_Owner.Read(exINI, pSection, "Message.Activated.Owner"); this->Message_Activated_Allies.Read(exINI, pSection, "Message.Activated.Allies"); this->Message_Activated_Enemies.Read(exINI, pSection, "Message.Activated.Enemies"); diff --git a/src/Ext/SWType/Body.h b/src/Ext/SWType/Body.h index e064cdcfdb..f95077d09a 100644 --- a/src/Ext/SWType/Body.h +++ b/src/Ext/SWType/Body.h @@ -125,6 +125,9 @@ class SWTypeExt final : public AbstractTypeExt ValueableIdx EVA_Activated_Allies; ValueableIdx EVA_Activated_Enemies; + std::vector SW_GroupAs; + Valueable SW_GroupAs_SyncLongestCooldown; + SWTypeExt(SuperWeaponTypeClass* OwnerObject) : AbstractTypeExt(OwnerObject) , TypeID { "" } , Money_Amount { 0 } @@ -213,6 +216,8 @@ class SWTypeExt final : public AbstractTypeExt , EVA_Activated_Owner { -1 } , EVA_Activated_Allies { -1 } , EVA_Activated_Enemies { -1 } + , SW_GroupAs { } + , SW_GroupAs_SyncLongestCooldown { false } { } // Ares 0.A functions @@ -239,6 +244,8 @@ class SWTypeExt final : public AbstractTypeExt void ApplyLinkedSW(SuperClass* pSW); + void ApplyGroupAsReset(SuperClass* pSW); + void ApplyActivatedMessage(SuperClass* pSW) const; void ApplyActivatedEva(SuperClass* pSW) const; diff --git a/src/Ext/SWType/FireSuperWeapon.cpp b/src/Ext/SWType/FireSuperWeapon.cpp index eae199788a..52cd6dd3a9 100644 --- a/src/Ext/SWType/FireSuperWeapon.cpp +++ b/src/Ext/SWType/FireSuperWeapon.cpp @@ -34,6 +34,9 @@ void SWTypeExt::FireSuperWeaponExt(SuperClass* pSW, const CellStruct& cell) if (pTypeExt->SW_Link.size() > 0) pTypeExt->ApplyLinkedSW(pSW); + if (pTypeExt->SW_GroupAs.size() > 0) + pTypeExt->ApplyGroupAsReset(pSW); + if (static_cast(pType->Type) == 28 && !pTypeExt->EMPulse_TargetSelf) // Ares' Type=EMPulse SW pTypeExt->HandleEMPulseLaunch(pSW, cell); @@ -488,6 +491,73 @@ void SWTypeExt::ApplyLinkedSW(SuperClass* pSW) } } +void SWTypeExt::ApplyGroupAsReset(SuperClass* pSW) +{ + if (!pSW || !pSW->Owner) + return; + + HouseClass* pHouse = pSW->Owner; + const std::vector& firedGroups = this->SW_GroupAs; + + if (firedGroups.empty()) + return; + + auto sharesFiredGroup = [&firedGroups](SWTypeExt* pOtherExt) -> bool + { + if (!pOtherExt || pOtherExt->SW_GroupAs.empty()) + return false; + + for (const std::string& group : firedGroups) + { + for (const std::string& otherGroup : pOtherExt->SW_GroupAs) + { + if (_stricmp(group.c_str(), otherGroup.c_str()) == 0) + return true; + } + } + + return false; + }; + + std::vector affectedSupers; + int maxRechargeTime = 0; + bool syncLongest = this->SW_GroupAs_SyncLongestCooldown; + + for (int i = 0; i < pHouse->Supers.Count; ++i) + { + SuperClass* pOtherSuper = pHouse->Supers.GetItemOrDefault(i); + + if (!pOtherSuper || !pOtherSuper->IsPresent || !pOtherSuper->Type) + continue; + + SWTypeExt* pOtherExt = SWTypeExt::Fetch(pOtherSuper->Type); + + if (sharesFiredGroup(pOtherExt)) + { + affectedSupers.push_back(pOtherSuper); + + int rechargeTime = pOtherSuper->GetRechargeTime(); + + if (rechargeTime > maxRechargeTime) + maxRechargeTime = rechargeTime; + + if (pOtherExt->SW_GroupAs_SyncLongestCooldown) + syncLongest = true; + } + } + + for (SuperClass* pSuperToReset : affectedSupers) + { + pSuperToReset->Reset(); + + if (syncLongest && maxRechargeTime > 0) + pSuperToReset->RechargeTimer.Start(maxRechargeTime); + } + + if (pHouse->IsCurrentPlayer()) + MouseClass::Instance.RepaintSidebar(1); +} + void SWTypeExt::ApplyActivatedMessage(SuperClass* pSW) const { const auto pHouse = pSW->Owner; From bf62eac4c9a73dab8413dc9a52c263812a9b37b4 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 09:26:13 +0200 Subject: [PATCH 02/14] Rename feature tags to SW.CooldownGroup and SW.CooldownGroup.SyncLongest - Update tags to SW.CooldownGroup and SW.CooldownGroup.SyncLongest - Update documentation heading to 'Cooldown groups' and clarify superweapons terminology --- docs/New-or-Enhanced-Logics.md | 14 +++++++------- src/Ext/SWType/Body.cpp | 8 ++++---- src/Ext/SWType/Body.h | 10 +++++----- src/Ext/SWType/FireSuperWeapon.cpp | 16 ++++++++-------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index a75e5ba3fb..0b6470e52d 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -1447,18 +1447,18 @@ Message.LinkedSWAcquired= ; CSF entry key EVA.LinkedSWAcquired= ; EVA entry ``` -### Shared Cooldown / Group Superweapons +### Cooldown groups -- Superweapons can be grouped together using `SW.GroupAs`. When any superweapon belonging to a group is launched, all other currently active (`IsPresent`) superweapons belonging to that group for the firer house will have their countdown timers reset (`RechargeTimer`), implementing a shared cooldown mechanism. -- `SW.GroupAs` accepts a comma-separated list of group names, allowing a superweapon to belong to multiple groups simultaneously. - - The reset is non-transitive: launching a superweapon only resets weapons that share at least one group with the *fired* weapon. Weapons reset collaterally do not propagate resets to other groups. -- `SW.GroupAs.SyncLongestCooldown` controls whether all present superweapons in the affected group(s) synchronize their cooldown to the longest `RechargeTime` among the superweapons currently active (built) in the group for that player. If set to `false`, each superweapon resets to its own individual recharge time. +- Superweapons can be grouped together using `SW.CooldownGroup`. When any superweapon belonging to a group is launched, all other currently active (`IsPresent`) superweapons belonging to that group for the firer house will have their countdown timers reset (`RechargeTimer`), implementing a shared cooldown mechanism. +- `SW.CooldownGroup` accepts a comma-separated list of group names, allowing a superweapon to belong to multiple groups simultaneously. + - The reset is non-transitive: launching a superweapon only resets superweapons that share at least one group with the *launched* superweapon. Superweapons reset collaterally do not propagate resets to other groups. +- `SW.CooldownGroup.SyncLongest` controls whether all present superweapons in the affected group(s) synchronize their cooldown to the longest `RechargeTime` among the superweapons currently active (built) in the group for that player. If set to `false`, each superweapon resets to its own individual recharge time. In `rulesmd.ini`: ```ini [SOMESW] ; SuperWeaponType -SW.GroupAs= ; List of group names (strings separated by commas) -SW.GroupAs.SyncLongestCooldown=false ; boolean +SW.CooldownGroup= ; List of group names (strings separated by commas) +SW.CooldownGroup.SyncLongest=false ; boolean ``` ### Next diff --git a/src/Ext/SWType/Body.cpp b/src/Ext/SWType/Body.cpp index 7d714cf82b..81b36bb6b1 100644 --- a/src/Ext/SWType/Body.cpp +++ b/src/Ext/SWType/Body.cpp @@ -106,8 +106,8 @@ void SWTypeExt::Serialize(T& Stm) .Process(this->EVA_Activated_Owner) .Process(this->EVA_Activated_Allies) .Process(this->EVA_Activated_Enemies) - .Process(this->SW_GroupAs) - .Process(this->SW_GroupAs_SyncLongestCooldown) + .Process(this->SW_CooldownGroup) + .Process(this->SW_CooldownGroup_SyncLongest) ; } @@ -244,8 +244,8 @@ void SWTypeExt::LoadFromINIFile(CCINIClass* const pINI) this->EVA_LinkedSWAcquired.Read(exINI, pSection, "EVA.LinkedSWAcquired"); this->SW_Link_RollChances.Read(exINI, pSection, "SW.Link.RollChances"); - exINI.ParseStringList(this->SW_GroupAs, pSection, "SW.GroupAs"); - this->SW_GroupAs_SyncLongestCooldown.Read(exINI, pSection, "SW.GroupAs.SyncLongestCooldown"); + exINI.ParseStringList(this->SW_CooldownGroup, pSection, "SW.CooldownGroup"); + this->SW_CooldownGroup_SyncLongest.Read(exINI, pSection, "SW.CooldownGroup.SyncLongest"); this->Message_Activated_Owner.Read(exINI, pSection, "Message.Activated.Owner"); this->Message_Activated_Allies.Read(exINI, pSection, "Message.Activated.Allies"); diff --git a/src/Ext/SWType/Body.h b/src/Ext/SWType/Body.h index f95077d09a..a0b13d83d4 100644 --- a/src/Ext/SWType/Body.h +++ b/src/Ext/SWType/Body.h @@ -125,8 +125,8 @@ class SWTypeExt final : public AbstractTypeExt ValueableIdx EVA_Activated_Allies; ValueableIdx EVA_Activated_Enemies; - std::vector SW_GroupAs; - Valueable SW_GroupAs_SyncLongestCooldown; + std::vector SW_CooldownGroup; + Valueable SW_CooldownGroup_SyncLongest; SWTypeExt(SuperWeaponTypeClass* OwnerObject) : AbstractTypeExt(OwnerObject) , TypeID { "" } @@ -216,8 +216,8 @@ class SWTypeExt final : public AbstractTypeExt , EVA_Activated_Owner { -1 } , EVA_Activated_Allies { -1 } , EVA_Activated_Enemies { -1 } - , SW_GroupAs { } - , SW_GroupAs_SyncLongestCooldown { false } + , SW_CooldownGroup { } + , SW_CooldownGroup_SyncLongest { false } { } // Ares 0.A functions @@ -244,7 +244,7 @@ class SWTypeExt final : public AbstractTypeExt void ApplyLinkedSW(SuperClass* pSW); - void ApplyGroupAsReset(SuperClass* pSW); + void ApplyCooldownGroupReset(SuperClass* pSW); void ApplyActivatedMessage(SuperClass* pSW) const; void ApplyActivatedEva(SuperClass* pSW) const; diff --git a/src/Ext/SWType/FireSuperWeapon.cpp b/src/Ext/SWType/FireSuperWeapon.cpp index 52cd6dd3a9..3600690548 100644 --- a/src/Ext/SWType/FireSuperWeapon.cpp +++ b/src/Ext/SWType/FireSuperWeapon.cpp @@ -34,8 +34,8 @@ void SWTypeExt::FireSuperWeaponExt(SuperClass* pSW, const CellStruct& cell) if (pTypeExt->SW_Link.size() > 0) pTypeExt->ApplyLinkedSW(pSW); - if (pTypeExt->SW_GroupAs.size() > 0) - pTypeExt->ApplyGroupAsReset(pSW); + if (pTypeExt->SW_CooldownGroup.size() > 0) + pTypeExt->ApplyCooldownGroupReset(pSW); if (static_cast(pType->Type) == 28 && !pTypeExt->EMPulse_TargetSelf) // Ares' Type=EMPulse SW pTypeExt->HandleEMPulseLaunch(pSW, cell); @@ -491,25 +491,25 @@ void SWTypeExt::ApplyLinkedSW(SuperClass* pSW) } } -void SWTypeExt::ApplyGroupAsReset(SuperClass* pSW) +void SWTypeExt::ApplyCooldownGroupReset(SuperClass* pSW) { if (!pSW || !pSW->Owner) return; HouseClass* pHouse = pSW->Owner; - const std::vector& firedGroups = this->SW_GroupAs; + const std::vector& firedGroups = this->SW_CooldownGroup; if (firedGroups.empty()) return; auto sharesFiredGroup = [&firedGroups](SWTypeExt* pOtherExt) -> bool { - if (!pOtherExt || pOtherExt->SW_GroupAs.empty()) + if (!pOtherExt || pOtherExt->SW_CooldownGroup.empty()) return false; for (const std::string& group : firedGroups) { - for (const std::string& otherGroup : pOtherExt->SW_GroupAs) + for (const std::string& otherGroup : pOtherExt->SW_CooldownGroup) { if (_stricmp(group.c_str(), otherGroup.c_str()) == 0) return true; @@ -521,7 +521,7 @@ void SWTypeExt::ApplyGroupAsReset(SuperClass* pSW) std::vector affectedSupers; int maxRechargeTime = 0; - bool syncLongest = this->SW_GroupAs_SyncLongestCooldown; + bool syncLongest = this->SW_CooldownGroup_SyncLongest; for (int i = 0; i < pHouse->Supers.Count; ++i) { @@ -541,7 +541,7 @@ void SWTypeExt::ApplyGroupAsReset(SuperClass* pSW) if (rechargeTime > maxRechargeTime) maxRechargeTime = rechargeTime; - if (pOtherExt->SW_GroupAs_SyncLongestCooldown) + if (pOtherExt->SW_CooldownGroup_SyncLongest) syncLongest = true; } } From c037e03b4a181f60892d5fabb329ec2315999a4d Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 09:41:42 +0200 Subject: [PATCH 03/14] Implement AI fair random selection for SW.CooldownGroup ready superweapons - Add HouseExt::AI_TryFireSW wrapper to randomly choose between ready superweapons sharing a cooldown group - Use synchronized ScenarioClass::Instance->Random to prevent multiplayer desyncs - Temporarily mask non-chosen superweapons during Ares evaluation loop and restore if not fired - Wire hooks at 0x4FD799 and 0x4F9038 - Document AI fair selection behavior in docs/New-or-Enhanced-Logics.md --- docs/New-or-Enhanced-Logics.md | 1 + src/Ext/House/Body.h | 1 + src/Ext/House/Hooks.cpp | 125 ++++++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index 0b6470e52d..c4f1949829 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -1453,6 +1453,7 @@ EVA.LinkedSWAcquired= ; EVA entry - `SW.CooldownGroup` accepts a comma-separated list of group names, allowing a superweapon to belong to multiple groups simultaneously. - The reset is non-transitive: launching a superweapon only resets superweapons that share at least one group with the *launched* superweapon. Superweapons reset collaterally do not propagate resets to other groups. - `SW.CooldownGroup.SyncLongest` controls whether all present superweapons in the affected group(s) synchronize their cooldown to the longest `RechargeTime` among the superweapons currently active (built) in the group for that player. If set to `false`, each superweapon resets to its own individual recharge time. +- If the AI has multiple superweapons in the same group ready to fire simultaneously, it automatically and fairly selects one candidate at random each frame (using the engine's synchronized PRNG, preventing desyncs in multiplayer) rather than always defaulting to the first superweapon defined in `[SuperWeaponTypes]`. In `rulesmd.ini`: ```ini diff --git a/src/Ext/House/Body.h b/src/Ext/House/Body.h index df5ba629b6..c0c82abdb6 100644 --- a/src/Ext/House/Body.h +++ b/src/Ext/House/Body.h @@ -173,6 +173,7 @@ class HouseExt final : public AbstractExt, public Detach::Listener #include +#include #include "Ext/Techno/Body.h" #include "Ext/Building/Body.h" #include @@ -453,6 +454,119 @@ DEFINE_HOOK(0x50B669, HouseClass_ShouldDisableCameo_GreyCameo, 0x3) return 0; } +// Wrapper around Ares' HouseClass::AI_TryFireSW (0x5098F0). +// When multiple superweapons in the same SW.CooldownGroup are ready at once, +// picks one candidate at random via the synchronized PRNG and temporarily masks +// the others so Ares doesn't always default to the first one in the vector. +void HouseExt::AI_TryFireSW(HouseClass* pThis) +{ + if (!pThis) + return; + + if (pThis->IsControlledByHuman()) + { + pThis->AI_TryFireSW(); + return; + } + + std::vector readySupers; + + for (int i = 0; i < pThis->Supers.Count; ++i) + { + SuperClass* pSuper = pThis->Supers.GetItemOrDefault(i); + + if (!pSuper || !pSuper->IsPresent || !pSuper->IsReady || !pSuper->Type) + continue; + + if (pSuper->ChargeDrainState == ChargeDrainState::Draining) + continue; + + SWTypeExt* pExt = SWTypeExt::Fetch(pSuper->Type); + + if (pExt && !pExt->SW_CooldownGroup.empty()) + readySupers.push_back(pSuper); + } + + std::vector maskedSupers; + + if (readySupers.size() > 1) + { + std::vector visited(readySupers.size(), false); + + for (size_t i = 0; i < readySupers.size(); ++i) + { + if (visited[i]) + continue; + + std::vector cluster; + cluster.push_back(readySupers[i]); + visited[i] = true; + + SWTypeExt* pExtI = SWTypeExt::Fetch(readySupers[i]->Type); + + for (size_t j = i + 1; j < readySupers.size(); ++j) + { + if (visited[j]) + continue; + + SWTypeExt* pExtJ = SWTypeExt::Fetch(readySupers[j]->Type); + bool sharesGroup = false; + + for (SuperClass* pMember : cluster) + { + SWTypeExt* pMemberExt = SWTypeExt::Fetch(pMember->Type); + + for (const std::string& group1 : pMemberExt->SW_CooldownGroup) + { + for (const std::string& group2 : pExtJ->SW_CooldownGroup) + { + if (_stricmp(group1.c_str(), group2.c_str()) == 0) + { + sharesGroup = true; + break; + } + } + + if (sharesGroup) + break; + } + + if (sharesGroup) + break; + } + + if (sharesGroup) + { + cluster.push_back(readySupers[j]); + visited[j] = true; + } + } + + if (cluster.size() > 1) + { + int chosenIndex = ScenarioClass::Instance->Random.RandomRanged(0, static_cast(cluster.size()) - 1); + + for (size_t k = 0; k < cluster.size(); ++k) + { + if (static_cast(k) != chosenIndex) + { + cluster[k]->IsReady = false; + maskedSupers.push_back(cluster[k]); + } + } + } + } + } + + pThis->AI_TryFireSW(); + + for (SuperClass* pMasked : maskedSupers) + { + if (pMasked->RechargeTimer.TimeLeft == 0) + pMasked->IsReady = true; + } +} + DEFINE_HOOK(0x4FD77C, HouseClass_ExpertAI_Superweapons, 0x5) { enum { SkipSWProcess = 0x4FD7A0 }; @@ -463,6 +577,15 @@ DEFINE_HOOK(0x4FD77C, HouseClass_ExpertAI_Superweapons, 0x5) return 0; } +DEFINE_HOOK(0x4FD799, HouseClass_ExpertAI_TryFireSW, 0x7) +{ + GET(HouseClass*, pThis, EBX); + + HouseExt::AI_TryFireSW(pThis); + + return 0x4FD7A0; +} + DEFINE_HOOK(0x4F9038, HouseClass_AI_Superweapons, 0x5) { GET(HouseClass*, pThis, ESI); @@ -483,7 +606,7 @@ DEFINE_HOOK(0x4F9038, HouseClass_AI_Superweapons, 0x5) } if (!SessionClass::IsCampaign() || pThis->IQLevel2 >= RulesClass::Instance->SuperWeapons) - pThis->AI_TryFireSW(); + HouseExt::AI_TryFireSW(pThis); return 0; } From 26689b20403090039351374476d70afda2095b03 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 09:48:17 +0200 Subject: [PATCH 04/14] Refine AI selection logging and documentation - Rename function to AI_TryFireSW_CooldownGroupAware to prevent confusion - Add Debug::Log mentioning house ID, group name, and randomly selected candidate - Clean up PRNG implementation details from documentation --- docs/New-or-Enhanced-Logics.md | 2 +- src/Ext/House/Body.h | 2 +- src/Ext/House/Hooks.cpp | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index c4f1949829..299f734712 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -1453,7 +1453,7 @@ EVA.LinkedSWAcquired= ; EVA entry - `SW.CooldownGroup` accepts a comma-separated list of group names, allowing a superweapon to belong to multiple groups simultaneously. - The reset is non-transitive: launching a superweapon only resets superweapons that share at least one group with the *launched* superweapon. Superweapons reset collaterally do not propagate resets to other groups. - `SW.CooldownGroup.SyncLongest` controls whether all present superweapons in the affected group(s) synchronize their cooldown to the longest `RechargeTime` among the superweapons currently active (built) in the group for that player. If set to `false`, each superweapon resets to its own individual recharge time. -- If the AI has multiple superweapons in the same group ready to fire simultaneously, it automatically and fairly selects one candidate at random each frame (using the engine's synchronized PRNG, preventing desyncs in multiplayer) rather than always defaulting to the first superweapon defined in `[SuperWeaponTypes]`. +- If the AI has multiple superweapons in the same group ready to fire simultaneously, it automatically and fairly selects one candidate at random each frame rather than always defaulting to the first superweapon defined in `[SuperWeaponTypes]`. In `rulesmd.ini`: ```ini diff --git a/src/Ext/House/Body.h b/src/Ext/House/Body.h index c0c82abdb6..ccc9a3ac61 100644 --- a/src/Ext/House/Body.h +++ b/src/Ext/House/Body.h @@ -173,7 +173,7 @@ class HouseExt final : public AbstractExt, public Detach::ListenerRandom.RandomRanged(0, static_cast(cluster.size()) - 1); + std::string groupNames; + + for (const std::string& groupName : pExtI->SW_CooldownGroup) + { + if (!groupNames.empty()) + groupNames += ", "; + + groupNames += groupName; + } + + Debug::Log("[SW.CooldownGroup] House '%s' has %d ready superweapons in group '%s'; randomly selected [%s].\n", + pThis->get_ID(), static_cast(cluster.size()), groupNames.c_str(), cluster[chosenIndex]->Type->get_ID()); + for (size_t k = 0; k < cluster.size(); ++k) { if (static_cast(k) != chosenIndex) @@ -581,7 +594,7 @@ DEFINE_HOOK(0x4FD799, HouseClass_ExpertAI_TryFireSW, 0x7) { GET(HouseClass*, pThis, EBX); - HouseExt::AI_TryFireSW(pThis); + HouseExt::AI_TryFireSW_CooldownGroupAware(pThis); return 0x4FD7A0; } @@ -606,7 +619,7 @@ DEFINE_HOOK(0x4F9038, HouseClass_AI_Superweapons, 0x5) } if (!SessionClass::IsCampaign() || pThis->IQLevel2 >= RulesClass::Instance->SuperWeapons) - HouseExt::AI_TryFireSW(pThis); + HouseExt::AI_TryFireSW_CooldownGroupAware(pThis); return 0; } From e91e8bb0d042c9afd17ca1330189ae27c39b6fdc Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 09:49:52 +0200 Subject: [PATCH 05/14] Clarify AI in comment before AI_TryFireSW_CooldownGroupAware --- src/Ext/House/Hooks.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ext/House/Hooks.cpp b/src/Ext/House/Hooks.cpp index 24122dc9f7..67d5d9ad99 100644 --- a/src/Ext/House/Hooks.cpp +++ b/src/Ext/House/Hooks.cpp @@ -456,7 +456,7 @@ DEFINE_HOOK(0x50B669, HouseClass_ShouldDisableCameo_GreyCameo, 0x3) // Wrapper around Ares' HouseClass::AI_TryFireSW (0x5098F0). // When multiple superweapons in the same SW.CooldownGroup are ready at once, -// picks one candidate at random via the synchronized PRNG and temporarily masks +// the AI picks one candidate at random via the synchronized PRNG and temporarily masks // the others so Ares doesn't always default to the first one in the vector. void HouseExt::AI_TryFireSW_CooldownGroupAware(HouseClass* pThis) { From e7250cf68b2071419b1a87c16930fd0b5d38f003 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 09:50:46 +0200 Subject: [PATCH 06/14] . --- docs/New-or-Enhanced-Logics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index 299f734712..20a54b936c 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -1453,7 +1453,7 @@ EVA.LinkedSWAcquired= ; EVA entry - `SW.CooldownGroup` accepts a comma-separated list of group names, allowing a superweapon to belong to multiple groups simultaneously. - The reset is non-transitive: launching a superweapon only resets superweapons that share at least one group with the *launched* superweapon. Superweapons reset collaterally do not propagate resets to other groups. - `SW.CooldownGroup.SyncLongest` controls whether all present superweapons in the affected group(s) synchronize their cooldown to the longest `RechargeTime` among the superweapons currently active (built) in the group for that player. If set to `false`, each superweapon resets to its own individual recharge time. -- If the AI has multiple superweapons in the same group ready to fire simultaneously, it automatically and fairly selects one candidate at random each frame rather than always defaulting to the first superweapon defined in `[SuperWeaponTypes]`. +- If the AI has multiple superweapons in the same group ready to fire simultaneously, it automatically and fairly selects one candidate at random each frame rather than always defaulting to the first superweapons defined in `[SuperWeaponTypes]`. In `rulesmd.ini`: ```ini From 97b7f74a1f6b46f5c7817bab358df2920c4b26d8 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 10:20:45 +0200 Subject: [PATCH 07/14] Use SetRechargeTime in ApplyCooldownGroupReset for syncLongest - Use pOtherSuper->Type->RechargeTime to find true maximum base recharge time - Call SetRechargeTime(maxRechargeTime) before Reset() to set CustomChargeTime - Add debug logging to report fired superweapon and calculated maximum recharge time --- src/Ext/SWType/FireSuperWeapon.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Ext/SWType/FireSuperWeapon.cpp b/src/Ext/SWType/FireSuperWeapon.cpp index 3600690548..37a142ce04 100644 --- a/src/Ext/SWType/FireSuperWeapon.cpp +++ b/src/Ext/SWType/FireSuperWeapon.cpp @@ -536,7 +536,7 @@ void SWTypeExt::ApplyCooldownGroupReset(SuperClass* pSW) { affectedSupers.push_back(pOtherSuper); - int rechargeTime = pOtherSuper->GetRechargeTime(); + int rechargeTime = pOtherSuper->Type->RechargeTime; if (rechargeTime > maxRechargeTime) maxRechargeTime = rechargeTime; @@ -546,12 +546,17 @@ void SWTypeExt::ApplyCooldownGroupReset(SuperClass* pSW) } } + Debug::Log("[SW.CooldownGroup] Fired [%s]. Resetting %d superweapons (syncLongest=%d, maxRechargeTime=%d frames / %.1f min).\n", + pSW->Type->get_ID(), static_cast(affectedSupers.size()), syncLongest ? 1 : 0, maxRechargeTime, maxRechargeTime / 900.0); + for (SuperClass* pSuperToReset : affectedSupers) { - pSuperToReset->Reset(); - if (syncLongest && maxRechargeTime > 0) - pSuperToReset->RechargeTimer.Start(maxRechargeTime); + pSuperToReset->SetRechargeTime(maxRechargeTime); + else + pSuperToReset->ResetRechargeTime(); + + pSuperToReset->Reset(); } if (pHouse->IsCurrentPlayer()) From 3464b82272f57367bf16a86b4a09104f0b3f7105 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 10:28:51 +0200 Subject: [PATCH 08/14] Fix EMPulse.SuspendOthers bug where secondary suspended SW stayed on hold - Correctly push pSuper->Type->ArrayIndex into SuspendedEMPulseSWs instead of arrayIndex - Ensures all suspended superweapons are properly resumed when EMP cannon finishes firing --- src/Ext/SWType/FireSuperWeapon.cpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Ext/SWType/FireSuperWeapon.cpp b/src/Ext/SWType/FireSuperWeapon.cpp index 37a142ce04..059f7b8d9c 100644 --- a/src/Ext/SWType/FireSuperWeapon.cpp +++ b/src/Ext/SWType/FireSuperWeapon.cpp @@ -398,12 +398,7 @@ void SWTypeExt::HandleEMPulseLaunch(SuperClass* pSW, const CellStruct& cell) con if (suspend) { pSuper->IsSuspended = true; - const int arrayIndex = pSW->Type->ArrayIndex; - - if (pHouseExt->SuspendedEMPulseSWs.count(arrayIndex)) - pHouseExt->SuspendedEMPulseSWs[arrayIndex].push_back(arrayIndex); - else - pHouseExt->SuspendedEMPulseSWs.insert({ arrayIndex, std::vector{pSuper->Type->ArrayIndex} }); + pHouseExt->SuspendedEMPulseSWs[pSW->Type->ArrayIndex].push_back(pSuper->Type->ArrayIndex); } } } From 03c8d413af1641fceaf993d0d30c82bee1a5ade7 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 11:09:48 +0200 Subject: [PATCH 09/14] Reset CustomChargeTime on superweapon grant and when charging completes - Hook SuperClass::Grant (0x6CB560) to reset custom charge time when a structure is built/rebuilt - Hook SuperClass::HasChargeProgressed (0x6CBDCC and 0x6CBDB7) to clear custom charge time once the superweapon reaches 100% Ready - Ensures rebuilt structures charge with their own native RechargeTime rather than stale group cooldowns --- src/Ext/SWType/Hooks.cpp | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/Ext/SWType/Hooks.cpp b/src/Ext/SWType/Hooks.cpp index 15ab51b00e..65c1a45af4 100644 --- a/src/Ext/SWType/Hooks.cpp +++ b/src/Ext/SWType/Hooks.cpp @@ -32,6 +32,36 @@ DEFINE_HOOK(0x6CDE40, SuperClass_Place_FireExt, 0x3) return 0; } +DEFINE_HOOK(0x6CB560, SuperClass_Grant_ResetRechargeTime, 0x5) +{ + GET(SuperClass*, pThis, ECX); + + if (pThis) + pThis->ResetRechargeTime(); + + return 0; +} + +DEFINE_HOOK(0x6CBDCC, SuperClass_HasChargeProgressed_Charged, 0x5) +{ + GET(SuperClass*, pThis, ESI); + + if (pThis) + pThis->ResetRechargeTime(); + + return 0; +} + +DEFINE_HOOK(0x6CBDB7, SuperClass_HasChargeProgressed_ChargedDrain, 0x6) +{ + GET(SuperClass*, pThis, ESI); + + if (pThis) + pThis->ResetRechargeTime(); + + return 0; +} + DEFINE_HOOK(0x6CB5EB, SuperClass_Grant_ShowTimer, 0x5) { GET(SuperClass*, pThis, ESI); From 52503558b700a7b4b15f44615bc0d61e430a223e Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 11:15:50 +0200 Subject: [PATCH 10/14] Show base and synchronized group recharge time in superweapon cameo tooltip - Display individual base recharge time - Append (MM:SS) with the longest present group recharge time if it exceeds base time - Do not append parentheses if group time equals base time --- src/Misc/PhobosToolTip.cpp | 60 +++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/Misc/PhobosToolTip.cpp b/src/Misc/PhobosToolTip.cpp index eaed57198b..374c4fa319 100644 --- a/src/Misc/PhobosToolTip.cpp +++ b/src/Misc/PhobosToolTip.cpp @@ -174,7 +174,7 @@ void PhobosToolTip::HelpText_Super(int swidx) showSth = true; } - const int rechargeTime = TickTimeToSeconds(pSuper->GetRechargeTime()); + const int rechargeTime = TickTimeToSeconds(pType->RechargeTime); if (rechargeTime > 0) { if (!showSth) @@ -186,6 +186,64 @@ void PhobosToolTip::HelpText_Super(int swidx) oss << (showSth ? L" " : L"") << Phobos::UI::TimeLabel << std::setw(2) << std::setfill(L'0') << nMin << L":" << std::setw(2) << std::setfill(L'0') << nSec; + + if (!pData->SW_CooldownGroup.empty()) + { + int maxGroupFrames = pType->RechargeTime; + bool syncLongest = pData->SW_CooldownGroup_SyncLongest; + HouseClass* pCurrentPlayer = HouseClass::CurrentPlayer; + + for (int i = 0; i < pCurrentPlayer->Supers.Count; ++i) + { + SuperClass* pOtherSuper = pCurrentPlayer->Supers.GetItemOrDefault(i); + + if (!pOtherSuper || !pOtherSuper->IsPresent || !pOtherSuper->Type) + continue; + + SWTypeExt* pOtherExt = SWTypeExt::Fetch(pOtherSuper->Type); + + if (!pOtherExt || pOtherExt->SW_CooldownGroup.empty()) + continue; + + bool sharesGroup = false; + + for (const std::string& g1 : pData->SW_CooldownGroup) + { + for (const std::string& g2 : pOtherExt->SW_CooldownGroup) + { + if (_stricmp(g1.c_str(), g2.c_str()) == 0) + { + sharesGroup = true; + break; + } + } + + if (sharesGroup) + break; + } + + if (sharesGroup) + { + if (pOtherSuper->Type->RechargeTime > maxGroupFrames) + maxGroupFrames = pOtherSuper->Type->RechargeTime; + + if (pOtherExt->SW_CooldownGroup_SyncLongest) + syncLongest = true; + } + } + + if (syncLongest && maxGroupFrames > pType->RechargeTime) + { + const int groupRechargeTime = TickTimeToSeconds(maxGroupFrames); + const int gSec = groupRechargeTime % 60; + const int gMin = groupRechargeTime / 60; + + oss << L" (" + << std::setw(2) << std::setfill(L'0') << gMin << L":" + << std::setw(2) << std::setfill(L'0') << gSec << L")"; + } + } + showSth = true; } From 9517b5e52be7fe9984d8860365003a9fe9d75b99 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 11:37:37 +0200 Subject: [PATCH 11/14] Update CREDITS.md and Whats-New.md for Superweapon cooldown groups - Add Superweapon cooldown groups to Whats-New.md under 0.6 New (by FS-21) - Add Superweapon cooldown groups under FS-21 in CREDITS.md --- CREDITS.md | 1 + docs/Whats-New.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CREDITS.md b/CREDITS.md index a01c6e0585..03a62edfed 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -161,6 +161,7 @@ This page lists all the individual contributions to the project by their author. - Unit & infantry auto-conversion on ammo change - Restore the ScriptType action#24 `Play speech` from Tiberian Sun - Modify ammo on impact + - Superweapon cooldown groups - **Starkku**: - Misc. minor bugfixes & improvements - AI script actions: diff --git a/docs/Whats-New.md b/docs/Whats-New.md index 94133d4c0b..f898129459 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -426,6 +426,7 @@ HideShakeEffects=false ; boolean #### New: - [Customized transport plane for teams](AI-Scripting-and-Mapping.md#customized-transport-plane-for-teams) (by FlyStar) - [Modify ammo on impact](New-or-Enhanced-Logics.md#modify-ammo-on-impact) (by FS-21) +- [Superweapon cooldown groups](New-or-Enhanced-Logics.md#cooldown-groups) (by FS-21) - [Customize ivan bomb visibility](Fixed-or-Improved-Logics.md#customize-ivan-bomb-visibility) (by NetsuNegi) - [Customize whether mind-controlled `Insignificant` technos can be auto-targeted](Fixed-or-Improved-Logics.md#customize-whether-mind-controlled-insignificant-technos-can-be-auto-targeted) (by Noble_Fish) - [AutoDeath based on player power status and player credits](New-or-Enhanced-Logics.md#kill-object-automatically) (by Flactine) From 5b9c85e9c44b5c0127525425a15e8dd674361c9c Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 12:10:12 +0200 Subject: [PATCH 12/14] Clarify SyncLongest group application rule in documentation - Explicitly state that if any active superweapon in the group has SyncLongest=true, the synchronized longest cooldown applies to the entire group - Clarify that individual reset times only occur if all active superweapons in the group have SyncLongest=false --- docs/New-or-Enhanced-Logics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index 20a54b936c..11a7df81b2 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -1452,7 +1452,7 @@ EVA.LinkedSWAcquired= ; EVA entry - Superweapons can be grouped together using `SW.CooldownGroup`. When any superweapon belonging to a group is launched, all other currently active (`IsPresent`) superweapons belonging to that group for the firer house will have their countdown timers reset (`RechargeTimer`), implementing a shared cooldown mechanism. - `SW.CooldownGroup` accepts a comma-separated list of group names, allowing a superweapon to belong to multiple groups simultaneously. - The reset is non-transitive: launching a superweapon only resets superweapons that share at least one group with the *launched* superweapon. Superweapons reset collaterally do not propagate resets to other groups. -- `SW.CooldownGroup.SyncLongest` controls whether all present superweapons in the affected group(s) synchronize their cooldown to the longest `RechargeTime` among the superweapons currently active (built) in the group for that player. If set to `false`, each superweapon resets to its own individual recharge time. +- `SW.CooldownGroup.SyncLongest` controls whether all present superweapons in the affected group(s) synchronize their cooldown to the longest `RechargeTime` among the superweapons currently active (built) in the group for that player. If any active superweapon in the group has this set to `true`, the synchronized longest cooldown applies to the entire group. If all active superweapons in the group have this set to `false`, each superweapon resets to its own individual recharge time. - If the AI has multiple superweapons in the same group ready to fire simultaneously, it automatically and fairly selects one candidate at random each frame rather than always defaulting to the first superweapons defined in `[SuperWeaponTypes]`. In `rulesmd.ini`: From 08ce8de2b67f40442fea99efebf497528eb43180 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 12:54:23 +0200 Subject: [PATCH 13/14] Implement [AI] -> RandomizeSuperWeaponPriority logic and documentation - Add RandomizeSuperWeaponPriority boolean tag under [AI] in rulesmd.ini (default false) - Shuffle ready candidate superweapons using synchronized engine PRNG (Random2Class) - Fall back gracefully to other ready candidates if first shuffled pick has no valid target - Add documentation in New-or-Enhanced-Logics.md, entry in Whats-New.md, and credit in CREDITS.md --- CREDITS.md | 1 + docs/New-or-Enhanced-Logics.md | 11 +++ docs/Whats-New.md | 1 + src/Ext/House/Hooks.cpp | 161 +++++++++++++++++++++------------ src/Ext/Rules/Body.cpp | 2 + src/Ext/Rules/Body.h | 2 + 6 files changed, 118 insertions(+), 60 deletions(-) diff --git a/CREDITS.md b/CREDITS.md index 03a62edfed..771efd8189 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -162,6 +162,7 @@ This page lists all the individual contributions to the project by their author. - Restore the ScriptType action#24 `Play speech` from Tiberian Sun - Modify ammo on impact - Superweapon cooldown groups + - Randomize AI superweapon priority - **Starkku**: - Misc. minor bugfixes & improvements - AI script actions: diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index 11a7df81b2..8df6d6c195 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -1486,6 +1486,17 @@ SW.Next.RollChances= ; List of percentages. SW.Next.RandomWeightsN= ; List of integers. ``` +### Randomize AI superweapon priority + +- AI houses now can evaluate their ready superweapons in a randomized order each firing cycle rather than always checking them in fixed array order. + - `RandomizeSuperWeaponPriority` controls whether AI houses randomize the evaluation order of all currently ready superweapons each firing cycle. When set to `true`, the AI shuffles all ready superweapons (including individual superweapons and group representatives) using the synchronized engine PRNG, eliminating the bias where superweapons defined earlier in `[SuperWeaponTypes]` are always prioritized. If the first randomly chosen candidate has no valid tactical target, the AI gracefully falls back to evaluate the remaining ready candidates in shuffled order. Defaults to `false`. + +In `rulesmd.ini`: +```ini +[AI] +RandomizeSuperWeaponPriority=false ; boolean +``` + ### Recipient-specific message and EVA on superweapon activation - Superweapons can now display messages and play EVA voices for specific recipient groups when activated. diff --git a/docs/Whats-New.md b/docs/Whats-New.md index f898129459..54e79c7279 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -427,6 +427,7 @@ HideShakeEffects=false ; boolean - [Customized transport plane for teams](AI-Scripting-and-Mapping.md#customized-transport-plane-for-teams) (by FlyStar) - [Modify ammo on impact](New-or-Enhanced-Logics.md#modify-ammo-on-impact) (by FS-21) - [Superweapon cooldown groups](New-or-Enhanced-Logics.md#cooldown-groups) (by FS-21) +- [Randomize AI superweapon priority](New-or-Enhanced-Logics.md#randomize-ai-superweapon-priority) (by FS-21) - [Customize ivan bomb visibility](Fixed-or-Improved-Logics.md#customize-ivan-bomb-visibility) (by NetsuNegi) - [Customize whether mind-controlled `Insignificant` technos can be auto-targeted](Fixed-or-Improved-Logics.md#customize-whether-mind-controlled-insignificant-technos-can-be-auto-targeted) (by Noble_Fish) - [AutoDeath based on player power status and player credits](New-or-Enhanced-Logics.md#kill-object-automatically) (by Flactine) diff --git a/src/Ext/House/Hooks.cpp b/src/Ext/House/Hooks.cpp index 67d5d9ad99..15a4582468 100644 --- a/src/Ext/House/Hooks.cpp +++ b/src/Ext/House/Hooks.cpp @@ -469,7 +469,7 @@ void HouseExt::AI_TryFireSW_CooldownGroupAware(HouseClass* pThis) return; } - std::vector readySupers; + std::vector allReadySupers; for (int i = 0; i < pThis->Supers.Count; ++i) { @@ -481,54 +481,57 @@ void HouseExt::AI_TryFireSW_CooldownGroupAware(HouseClass* pThis) if (pSuper->ChargeDrainState == ChargeDrainState::Draining) continue; - SWTypeExt* pExt = SWTypeExt::Fetch(pSuper->Type); - - if (pExt && !pExt->SW_CooldownGroup.empty()) - readySupers.push_back(pSuper); + allReadySupers.push_back(pSuper); } - std::vector maskedSupers; + if (allReadySupers.empty()) + return; + + std::vector inGroupCluster(allReadySupers.size(), false); + std::vector candidates; - if (readySupers.size() > 1) + for (size_t i = 0; i < allReadySupers.size(); ++i) { - std::vector visited(readySupers.size(), false); + if (inGroupCluster[i]) + continue; + + SWTypeExt* pExtI = SWTypeExt::Fetch(allReadySupers[i]->Type); + + if (!pExtI || pExtI->SW_CooldownGroup.empty()) + { + candidates.push_back(allReadySupers[i]); + continue; + } - for (size_t i = 0; i < readySupers.size(); ++i) + std::vector cluster; + cluster.push_back(allReadySupers[i]); + inGroupCluster[i] = true; + + for (size_t j = i + 1; j < allReadySupers.size(); ++j) { - if (visited[i]) + if (inGroupCluster[j]) continue; - std::vector cluster; - cluster.push_back(readySupers[i]); - visited[i] = true; + SWTypeExt* pExtJ = SWTypeExt::Fetch(allReadySupers[j]->Type); - SWTypeExt* pExtI = SWTypeExt::Fetch(readySupers[i]->Type); + if (!pExtJ || pExtJ->SW_CooldownGroup.empty()) + continue; - for (size_t j = i + 1; j < readySupers.size(); ++j) - { - if (visited[j]) - continue; + bool sharesGroup = false; - SWTypeExt* pExtJ = SWTypeExt::Fetch(readySupers[j]->Type); - bool sharesGroup = false; + for (SuperClass* pMember : cluster) + { + SWTypeExt* pMemberExt = SWTypeExt::Fetch(pMember->Type); - for (SuperClass* pMember : cluster) + for (const std::string& group1 : pMemberExt->SW_CooldownGroup) { - SWTypeExt* pMemberExt = SWTypeExt::Fetch(pMember->Type); - - for (const std::string& group1 : pMemberExt->SW_CooldownGroup) + for (const std::string& group2 : pExtJ->SW_CooldownGroup) { - for (const std::string& group2 : pExtJ->SW_CooldownGroup) + if (_stricmp(group1.c_str(), group2.c_str()) == 0) { - if (_stricmp(group1.c_str(), group2.c_str()) == 0) - { - sharesGroup = true; - break; - } - } - - if (sharesGroup) + sharesGroup = true; break; + } } if (sharesGroup) @@ -536,47 +539,85 @@ void HouseExt::AI_TryFireSW_CooldownGroupAware(HouseClass* pThis) } if (sharesGroup) - { - cluster.push_back(readySupers[j]); - visited[j] = true; - } + break; } - if (cluster.size() > 1) + if (sharesGroup) { - int chosenIndex = ScenarioClass::Instance->Random.RandomRanged(0, static_cast(cluster.size()) - 1); + cluster.push_back(allReadySupers[j]); + inGroupCluster[j] = true; + } + } - std::string groupNames; + if (cluster.size() > 1) + { + int chosenIndex = ScenarioClass::Instance->Random.RandomRanged(0, static_cast(cluster.size()) - 1); - for (const std::string& groupName : pExtI->SW_CooldownGroup) - { - if (!groupNames.empty()) - groupNames += ", "; + std::string groupNames; - groupNames += groupName; - } + for (const std::string& groupName : pExtI->SW_CooldownGroup) + { + if (!groupNames.empty()) + groupNames += ", "; - Debug::Log("[SW.CooldownGroup] House '%s' has %d ready superweapons in group '%s'; randomly selected [%s].\n", - pThis->get_ID(), static_cast(cluster.size()), groupNames.c_str(), cluster[chosenIndex]->Type->get_ID()); + groupNames += groupName; + } - for (size_t k = 0; k < cluster.size(); ++k) - { - if (static_cast(k) != chosenIndex) - { - cluster[k]->IsReady = false; - maskedSupers.push_back(cluster[k]); - } - } + Debug::Log("[SW.CooldownGroup] House '%s' has %d ready superweapons in group '%s'; randomly selected [%s].\n", + pThis->get_ID(), static_cast(cluster.size()), groupNames.c_str(), cluster[chosenIndex]->Type->get_ID()); + + for (size_t k = 0; k < cluster.size(); ++k) + { + if (static_cast(k) != chosenIndex) + cluster[k]->IsReady = false; } + + candidates.push_back(cluster[chosenIndex]); } + else + { + candidates.push_back(allReadySupers[i]); + } + } + + const bool randomizePriority = RulesExt::Global()->RandomizeSuperWeaponPriority.Get(); + + if (!randomizePriority || candidates.size() <= 1) + { + pThis->AI_TryFireSW(); } + else + { + // Fisher-Yates shuffle of candidates using synchronized engine PRNG + for (int i = static_cast(candidates.size()) - 1; i > 0; --i) + { + int j = ScenarioClass::Instance->Random.RandomRanged(0, i); + std::swap(candidates[i], candidates[j]); + } - pThis->AI_TryFireSW(); + for (size_t i = 0; i < candidates.size(); ++i) + { + SuperClass* pCandidate = candidates[i]; + + for (size_t j = 0; j < candidates.size(); ++j) + { + if (j != i) + candidates[j]->IsReady = false; + } + + pCandidate->IsReady = true; + + pThis->AI_TryFireSW(); + + if (!pCandidate->IsReady || pCandidate->RechargeTimer.TimeLeft > 0) + break; + } + } - for (SuperClass* pMasked : maskedSupers) + for (SuperClass* pSuper : allReadySupers) { - if (pMasked->RechargeTimer.TimeLeft == 0) - pMasked->IsReady = true; + if (pSuper->RechargeTimer.TimeLeft == 0 && pSuper->IsPresent) + pSuper->IsReady = true; } } diff --git a/src/Ext/Rules/Body.cpp b/src/Ext/Rules/Body.cpp index a80aeac4ec..69900bbf52 100644 --- a/src/Ext/Rules/Body.cpp +++ b/src/Ext/Rules/Body.cpp @@ -351,6 +351,7 @@ void RulesExt::ExtData::LoadBeforeTypeData(RulesClass* pThis, CCINIClass* pINI) this->AIForbidConYard.Read(exINI, GameStrings::AI, "AIForbidConYard"); this->AINodeWallsOnly.Read(exINI, GameStrings::AI, "AINodeWallsOnly"); this->AICleanWallNode.Read(exINI, GameStrings::AI, "AICleanWallNode"); + this->RandomizeSuperWeaponPriority.Read(exINI, GameStrings::AI, "RandomizeSuperWeaponPriority"); this->AttackMove_Aggressive.Read(exINI, GameStrings::General, "AttackMove.Aggressive"); this->AttackMove_UpdateTarget.Read(exINI, GameStrings::General, "AttackMove.UpdateTarget"); @@ -935,6 +936,7 @@ void RulesExt::ExtData::Serialize(T& Stm) .Process(this->AIForbidConYard) .Process(this->AINodeWallsOnly) .Process(this->AICleanWallNode) + .Process(this->RandomizeSuperWeaponPriority) .Process(this->AttackMove_Aggressive) .Process(this->AttackMove_UpdateTarget) .Process(this->MindControl_ThreatDelay) diff --git a/src/Ext/Rules/Body.h b/src/Ext/Rules/Body.h index 637e089968..bac0b10405 100644 --- a/src/Ext/Rules/Body.h +++ b/src/Ext/Rules/Body.h @@ -278,6 +278,7 @@ class RulesExt Valueable AIForbidConYard; Valueable AINodeWallsOnly; Valueable AICleanWallNode; + Valueable RandomizeSuperWeaponPriority; Valueable AttackMove_Aggressive; Valueable AttackMove_UpdateTarget; @@ -789,6 +790,7 @@ class RulesExt , AIForbidConYard { false } , AINodeWallsOnly { false } , AICleanWallNode { false } + , RandomizeSuperWeaponPriority { false } , AttackMove_Aggressive { false } , AttackMove_UpdateTarget { false } , MindControl_ThreatDelay { 0 } From 49b1e1773631a7c91075e132c3f78755b2799035 Mon Sep 17 00:00:00 2001 From: FS-21 Date: Thu, 3 Sep 2026 16:17:33 +0200 Subject: [PATCH 14/14] Small fix --- src/Ext/House/Hooks.cpp | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Ext/House/Hooks.cpp b/src/Ext/House/Hooks.cpp index 15a4582468..97227d8561 100644 --- a/src/Ext/House/Hooks.cpp +++ b/src/Ext/House/Hooks.cpp @@ -481,6 +481,16 @@ void HouseExt::AI_TryFireSW_CooldownGroupAware(HouseClass* pThis) if (pSuper->ChargeDrainState == ChargeDrainState::Draining) continue; + auto const pExt = SWTypeExt::Fetch(pSuper->Type); + + if (pExt && pExt->SW_Shots > 0) + { + auto const pHouseExt = HouseExt::Fetch(pThis); + + if (pHouseExt && pHouseExt->SuperExts[pSuper->Type->ArrayIndex].ShotCount >= pExt->SW_Shots) + continue; + } + allReadySupers.push_back(pSuper); } @@ -580,11 +590,21 @@ void HouseExt::AI_TryFireSW_CooldownGroupAware(HouseClass* pThis) } } + SuperClass* pFired = nullptr; const bool randomizePriority = RulesExt::Global()->RandomizeSuperWeaponPriority.Get(); if (!randomizePriority || candidates.size() <= 1) { pThis->AI_TryFireSW(); + + for (SuperClass* pCand : candidates) + { + if (!pCand->IsReady || pCand->RechargeTimer.TimeLeft > 0) + { + pFired = pCand; + break; + } + } } else { @@ -610,14 +630,29 @@ void HouseExt::AI_TryFireSW_CooldownGroupAware(HouseClass* pThis) pThis->AI_TryFireSW(); if (!pCandidate->IsReady || pCandidate->RechargeTimer.TimeLeft > 0) + { + pFired = pCandidate; break; + } } } + auto const pHouseExt = HouseExt::Fetch(pThis); + for (SuperClass* pSuper : allReadySupers) { - if (pSuper->RechargeTimer.TimeLeft == 0 && pSuper->IsPresent) + if (pSuper != pFired && pSuper->IsPresent && pSuper->RechargeTimer.TimeLeft == 0) + { + auto const pExt = SWTypeExt::Fetch(pSuper->Type); + + if (pExt && pExt->SW_Shots > 0 && pHouseExt) + { + if (pHouseExt->SuperExts[pSuper->Type->ArrayIndex].ShotCount >= pExt->SW_Shots) + continue; + } + pSuper->IsReady = true; + } } }