Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bb3a660
Implement SW.GroupAs and SW.GroupAs.SyncLongestCooldown shared cooldown
FS-21 Sep 3, 2026
bf62eac
Rename feature tags to SW.CooldownGroup and SW.CooldownGroup.SyncLongest
FS-21 Sep 3, 2026
c037e03
Implement AI fair random selection for SW.CooldownGroup ready superwe…
FS-21 Sep 3, 2026
26689b2
Refine AI selection logging and documentation
FS-21 Sep 3, 2026
e91e8bb
Clarify AI in comment before AI_TryFireSW_CooldownGroupAware
FS-21 Sep 3, 2026
e7250cf
.
FS-21 Sep 3, 2026
97b7f74
Use SetRechargeTime in ApplyCooldownGroupReset for syncLongest
FS-21 Sep 3, 2026
3464b82
Fix EMPulse.SuspendOthers bug where secondary suspended SW stayed on …
FS-21 Sep 3, 2026
03c8d41
Reset CustomChargeTime on superweapon grant and when charging completes
FS-21 Sep 3, 2026
5250355
Show base and synchronized group recharge time in superweapon cameo t…
FS-21 Sep 3, 2026
9517b5e
Update CREDITS.md and Whats-New.md for Superweapon cooldown groups
FS-21 Sep 3, 2026
5b9c85e
Clarify SyncLongest group application rule in documentation
FS-21 Sep 3, 2026
08ce8de
Implement [AI] -> RandomizeSuperWeaponPriority logic and documentation
FS-21 Sep 3, 2026
49b1e17
Small fix
FS-21 Sep 3, 2026
742e37a
Merge remote-tracking branch 'origin/develop' into feature/SW-Shared-…
FS-21 Sep 3, 2026
d258fb7
Merge branch 'develop' into feature/SW-Shared-Countdown
FS-21 Sep 23, 2026
ca05024
Update Whats-New.md
DeathFishAtEase Sep 28, 2026
e42856a
Merge branch 'develop' into feature/SW-Shared-Countdown
DeathFishAtEase Sep 28, 2026
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
2 changes: 2 additions & 0 deletions CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ This page lists all the individual contributions to the project by their author.
- Fix crashes and freezes caused by Tiberium growth and spread
- Fix Tiberium types not supporting overrides in map and game mode INIs
- Tiberium veins overlay palette fix
- Superweapon cooldown groups
- Randomize AI superweapon priority
- **Starkku**:
- Misc. minor bugfixes & improvements
- AI script actions:
Expand Down
26 changes: 26 additions & 0 deletions docs/New-or-Enhanced-Logics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,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)
Expand All @@ -1535,6 +1550,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.
Expand Down
2 changes: 2 additions & 0 deletions docs/Whats-New.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,8 @@ HideShakeEffects=false ; boolean
- [Hotkey for cycling through the current selection](User-Interface.md#cycle-selection) (by FrozenFog)
- [Hotkey for cycling through the types in the current selection](User-Interface.md#cycle-type-selection) (by FrozenFog)
- [Customizable type order and selection summary for the type cycle of the current selection](User-Interface.md#cycle-type-selection) (by FrozenFog)
- [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)

#### Vanilla fixes:
- Fixed the bug where a building with `Factory=BuildingType` owned by the AI did not play `ProductionAnim` when placing a produced building (by Noble_Fish)
Expand Down
1 change: 1 addition & 0 deletions src/Ext/House/Body.h
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ class HouseExt final : public AbstractExt, public Detach::Listener<BuildingClass
static HouseClass* GetHouseKind(OwnerHouseKind kind, bool allowRandom, HouseClass* pDefault, HouseClass* pInvoker = nullptr, HouseClass* pVictim = nullptr);
static CellClass* GetEnemyBaseGatherCell(HouseClass* pTargetHouse, HouseClass* pCurrentHouse, CoordStruct defaultCurrentCoords, SpeedType speedTypeZone, int extraDistance = 0);
static void GetAIChronoshiftSupers(HouseClass* pThis, SuperClass*& pSuperCSphere, SuperClass*& pSuperCWarp);
static void AI_TryFireSW_CooldownGroupAware(HouseClass* pThis);

static void SetForceOnlyTargetHouseEnemy(HouseClass* pThis, int mode = -1);
static void SetSkirmishHouseName(HouseClass* pHouse);
Expand Down
214 changes: 213 additions & 1 deletion src/Ext/House/Hooks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <Ext/Aircraft/Body.h>
#include <Ext/Scenario/Body.h>
#include <Ext/SWType/Body.h>
#include "Ext/Techno/Body.h"
#include "Ext/Building/Body.h"
#include <Ext/Event/Body.h>
Expand Down Expand Up @@ -435,6 +436,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<SuperClass*> 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<bool> inGroupCluster(allReadySupers.size(), false);
std::vector<SuperClass*> 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<SuperClass*> 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<int>(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<int>(cluster.size()), groupNames.c_str(), cluster[chosenIndex]->Type->get_ID());

for (size_t k = 0; k < cluster.size(); ++k)
{
if (static_cast<int>(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<int>(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 };
Expand All @@ -445,6 +648,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);
Expand All @@ -465,7 +677,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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/Ext/Rules/Body.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,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");
Expand Down Expand Up @@ -965,6 +966,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)
Expand Down
2 changes: 2 additions & 0 deletions src/Ext/Rules/Body.h
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ class RulesExt
Valueable<bool> AIForbidConYard;
Valueable<bool> AINodeWallsOnly;
Valueable<bool> AICleanWallNode;
Valueable<bool> RandomizeSuperWeaponPriority;

Valueable<bool> AttackMove_Aggressive;
Valueable<bool> AttackMove_UpdateTarget;
Expand Down Expand Up @@ -800,6 +801,7 @@ class RulesExt
, AIForbidConYard { false }
, AINodeWallsOnly { false }
, AICleanWallNode { false }
, RandomizeSuperWeaponPriority { false }
, AttackMove_Aggressive { false }
, AttackMove_UpdateTarget { false }
, MindControl_ThreatDelay { 0 }
Expand Down
5 changes: 5 additions & 0 deletions src/Ext/SWType/Body.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
;
}

Expand Down Expand Up @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions src/Ext/SWType/Body.h
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ class SWTypeExt final : public AbstractTypeExt
ValueableIdx<VoxClass> EVA_Activated_Allies;
ValueableIdx<VoxClass> EVA_Activated_Enemies;

std::vector<std::string> SW_CooldownGroup;
Valueable<bool> SW_CooldownGroup_SyncLongest;

SWTypeExt(SuperWeaponTypeClass* OwnerObject) : AbstractTypeExt(OwnerObject)
, TypeID { "" }
, Money_Amount { 0 }
Expand Down Expand Up @@ -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
Expand All @@ -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;

Expand Down
Loading
Loading