diff --git a/CREDITS.md b/CREDITS.md index 52f7b18ee7..fb9c088092 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -161,6 +161,8 @@ 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 + - 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 aae20369cb..8df6d6c195 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -1447,6 +1447,21 @@ Message.LinkedSWAcquired= ; CSF entry key EVA.LinkedSWAcquired= ; EVA entry ``` +### Cooldown groups + +- 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 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`: +```ini +[SOMESW] ; SuperWeaponType +SW.CooldownGroup= ; List of group names (strings separated by commas) +SW.CooldownGroup.SyncLongest=false ; boolean +``` + ### Next ![image](_static/images/swnext.gif) @@ -1471,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 49eb48c87d..ceb641d644 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -426,6 +426,8 @@ 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) +- [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/Body.h b/src/Ext/House/Body.h index df5ba629b6..ccc9a3ac61 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,208 @@ 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, +// 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) +{ + if (!pThis) + return; + + if (pThis->IsControlledByHuman()) + { + pThis->AI_TryFireSW(); + return; + } + + std::vector allReadySupers; + + 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; + + 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); + } + + if (allReadySupers.empty()) + return; + + std::vector inGroupCluster(allReadySupers.size(), false); + std::vector candidates; + + for (size_t i = 0; i < allReadySupers.size(); ++i) + { + if (inGroupCluster[i]) + continue; + + SWTypeExt* pExtI = SWTypeExt::Fetch(allReadySupers[i]->Type); + + if (!pExtI || pExtI->SW_CooldownGroup.empty()) + { + candidates.push_back(allReadySupers[i]); + continue; + } + + std::vector cluster; + cluster.push_back(allReadySupers[i]); + inGroupCluster[i] = true; + + for (size_t j = i + 1; j < allReadySupers.size(); ++j) + { + if (inGroupCluster[j]) + continue; + + SWTypeExt* pExtJ = SWTypeExt::Fetch(allReadySupers[j]->Type); + + if (!pExtJ || pExtJ->SW_CooldownGroup.empty()) + continue; + + 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(allReadySupers[j]); + inGroupCluster[j] = true; + } + } + + if (cluster.size() > 1) + { + int chosenIndex = ScenarioClass::Instance->Random.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) + cluster[k]->IsReady = false; + } + + candidates.push_back(cluster[chosenIndex]); + } + else + { + candidates.push_back(allReadySupers[i]); + } + } + + 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 + { + // 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]); + } + + 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) + { + pFired = pCandidate; + break; + } + } + } + + auto const pHouseExt = HouseExt::Fetch(pThis); + + for (SuperClass* pSuper : allReadySupers) + { + 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; + } + } +} + DEFINE_HOOK(0x4FD77C, HouseClass_ExpertAI_Superweapons, 0x5) { enum { SkipSWProcess = 0x4FD7A0 }; @@ -463,6 +666,15 @@ DEFINE_HOOK(0x4FD77C, HouseClass_ExpertAI_Superweapons, 0x5) return 0; } +DEFINE_HOOK(0x4FD799, HouseClass_ExpertAI_TryFireSW, 0x7) +{ + GET(HouseClass*, pThis, EBX); + + HouseExt::AI_TryFireSW_CooldownGroupAware(pThis); + + return 0x4FD7A0; +} + DEFINE_HOOK(0x4F9038, HouseClass_AI_Superweapons, 0x5) { GET(HouseClass*, pThis, ESI); @@ -483,7 +695,7 @@ DEFINE_HOOK(0x4F9038, HouseClass_AI_Superweapons, 0x5) } if (!SessionClass::IsCampaign() || pThis->IQLevel2 >= RulesClass::Instance->SuperWeapons) - pThis->AI_TryFireSW(); + HouseExt::AI_TryFireSW_CooldownGroupAware(pThis); return 0; } 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 } diff --git a/src/Ext/SWType/Body.cpp b/src/Ext/SWType/Body.cpp index 531a66fb0d..81b36bb6b1 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_CooldownGroup) + .Process(this->SW_CooldownGroup_SyncLongest) ; } @@ -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_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"); 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..a0b13d83d4 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_CooldownGroup; + Valueable SW_CooldownGroup_SyncLongest; + 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_CooldownGroup { } + , SW_CooldownGroup_SyncLongest { false } { } // Ares 0.A functions @@ -239,6 +244,8 @@ class SWTypeExt final : public AbstractTypeExt void ApplyLinkedSW(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 eae199788a..059f7b8d9c 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_CooldownGroup.size() > 0) + pTypeExt->ApplyCooldownGroupReset(pSW); + if (static_cast(pType->Type) == 28 && !pTypeExt->EMPulse_TargetSelf) // Ares' Type=EMPulse SW pTypeExt->HandleEMPulseLaunch(pSW, cell); @@ -395,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); } } } @@ -488,6 +486,78 @@ void SWTypeExt::ApplyLinkedSW(SuperClass* pSW) } } +void SWTypeExt::ApplyCooldownGroupReset(SuperClass* pSW) +{ + if (!pSW || !pSW->Owner) + return; + + HouseClass* pHouse = pSW->Owner; + const std::vector& firedGroups = this->SW_CooldownGroup; + + if (firedGroups.empty()) + return; + + auto sharesFiredGroup = [&firedGroups](SWTypeExt* pOtherExt) -> bool + { + if (!pOtherExt || pOtherExt->SW_CooldownGroup.empty()) + return false; + + for (const std::string& group : firedGroups) + { + for (const std::string& otherGroup : pOtherExt->SW_CooldownGroup) + { + if (_stricmp(group.c_str(), otherGroup.c_str()) == 0) + return true; + } + } + + return false; + }; + + std::vector affectedSupers; + int maxRechargeTime = 0; + bool syncLongest = this->SW_CooldownGroup_SyncLongest; + + 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->Type->RechargeTime; + + if (rechargeTime > maxRechargeTime) + maxRechargeTime = rechargeTime; + + if (pOtherExt->SW_CooldownGroup_SyncLongest) + syncLongest = true; + } + } + + 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) + { + if (syncLongest && maxRechargeTime > 0) + pSuperToReset->SetRechargeTime(maxRechargeTime); + else + pSuperToReset->ResetRechargeTime(); + + pSuperToReset->Reset(); + } + + if (pHouse->IsCurrentPlayer()) + MouseClass::Instance.RepaintSidebar(1); +} + void SWTypeExt::ApplyActivatedMessage(SuperClass* pSW) const { const auto pHouse = pSW->Owner; 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); 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; }