diff --git a/config/AcediaFixes.ini b/config/AcediaFixes.ini index 964bd70..0751582 100644 --- a/config/AcediaFixes.ini +++ b/config/AcediaFixes.ini @@ -1,4 +1,4 @@ -[AcediaFixes.FixDualiesCost] +[default FixDualiesCost] ; This feature fixes several issues, related to the selling price of both ; single and dual pistols, all originating from the existence of dual weapons. ; Most notable issue is the ability to "print" money by buying and @@ -43,7 +43,7 @@ autoEnable=true allowSellValueIncrease=true -[AcediaFixes.FixAmmoSelling] +[default FixAmmoSelling] ; This feature addressed an oversight in vanilla code that ; allows clients to sell weapon's ammunition. ; Due to the implementation of ammo selling, this allows cheaters to @@ -68,7 +68,7 @@ autoEnable=true allowNegativeDosh=false -[AcediaFixes.FixInventoryAbuse] +[default FixInventoryAbuse] ; This feature addressed two issues with the inventory: ; 1. Players carrying amount of weapons that shouldn't be allowed by the ; weight limit. @@ -94,7 +94,7 @@ dualiesClasses=(single=class'KFMod.GoldenDeaglePickup',dual=class'KFMod.GoldenDu dualiesClasses=(single=class'KFMod.FlareRevolverPickup',dual=class'KFMod.DualFlareRevolverPickup') -[AcediaFixes.FixInfiniteNades] +[default FixInfiniteNades] ; This feature fixes a vulnerability in a code of `Frag` that can allow ; player to throw grenades even when he no longer has any. ; There's also no cooldowns on the throw, which can lead to a server crash. @@ -106,7 +106,7 @@ autoEnable=true ignoreTossFlags=true -[AcediaFixes.FixDoshSpam] +[default FixDoshSpam] ; This feature addressed two dosh-related issues: ; 1. Crashing servers by spamming `CashPickup` actors with `TossCash()`; ; 2. Breaking collision detection logic by stacking large amount of @@ -142,7 +142,7 @@ criticalDoshAmount=25 checkInterval=0.25 -[AcediaFixes.FixSpectatorCrash] +[default FixSpectatorCrash] ; This feature attempts to prevent server crashes caused by someone ; quickly switching between being spectator and an active player. autoEnable=true @@ -170,7 +170,7 @@ spectatorChangeTimeout=0.25 allowServerBlock=true -[AcediaFixes.FixFFHack] +[default FixFFHack] ; This feature fixes a bug that can allow players to bypass server's ; friendly fire limitations and teamkill. ; Usual fixes apply friendly fire scale to suspicious damage themselves, which @@ -225,7 +225,7 @@ alwaysScale=Class'KFMod.DamTypeZEDGunMKII' ; Damage types, for which we should never reaply friendly fire scaling. ;neverScale=Class'KFMod.???' -[AcediaFixes.FixPipes] +[default FixPipes] ; This feature addresses several bugs related to pipe bombs: ; 1. Gaining extra explosive damage by shooting pipes with ; a high fire rate weapon; @@ -261,7 +261,7 @@ preventNPCDetonation=true proximityCheckElevation=20 -[AcediaFixes.FixProjectileFF] +[default FixProjectileFF] ; This feature addresses the bug that allows teammates to explode some of ; the player's projectiles by damaging them even when friendly fire is ; turned off, therefore killing the player (whether by accident or not). @@ -272,7 +272,7 @@ autoEnable=true ; what, even when server has full friendly fire enabled. ignoreFriendlyFire=false -[AcediaFixes.FixZedTimeLags] +[default FixZedTimeLags] ; When zed time activates, game speed is immediately set to ; `zedTimeSlomoScale` (0.2 by default), defined, like all other variables, ; in `KFGameType`. Zed time lasts `zedTimeDuration` seconds (3.0 by default), @@ -319,7 +319,7 @@ disableTick=true ; changes will be to avoid solutions that are way too "hacky" and prefer some ; message spam getting through to the possibility of some unexpected gameplay ; effects as far as vanilla game is concerned. -[AcediaFixes.FixLogSpam] +[default FixLogSpam] autoEnable=true ; This optionresponsible for fixing log spam ; due to picking up dropped weapons without set `inventory` variable. diff --git a/sources/FixAmmoSelling/AmmoPickupStalker.uc b/sources/FixAmmoSelling/AmmoPickupStalker.uc index 343bdab..d0dbc3a 100644 --- a/sources/FixAmmoSelling/AmmoPickupStalker.uc +++ b/sources/FixAmmoSelling/AmmoPickupStalker.uc @@ -51,13 +51,14 @@ public final static function StalkAmmoPickup(KFAmmoPickup newTarget) event Touch(Actor other) { - local FixAmmoSelling ammoSellingFix; + local FixAmmoSelling_Feature ammoSellingFix; if (target == none) return; // If our box was sleeping for while (more than a tick), - // player couldn't have gotten any ammo. if (!wasActive && !target.IsInState('Pickup')) return; - ammoSellingFix = FixAmmoSelling(class'FixAmmoSelling'.static.GetInstance()); + ammoSellingFix = FixAmmoSelling_Feature( + class'FixAmmoSelling_Feature'.static.GetInstance()); if (ammoSellingFix != none) { ammoSellingFix.RecordAmmoPickup(Pawn(other), target); } diff --git a/sources/FixAmmoSelling/FixAmmoSelling.uc b/sources/FixAmmoSelling/FixAmmoSelling.uc index d9a942b..28e8b3b 100644 --- a/sources/FixAmmoSelling/FixAmmoSelling.uc +++ b/sources/FixAmmoSelling/FixAmmoSelling.uc @@ -1,20 +1,6 @@ /** - * This feature addressed an oversight in vanilla code that - * allows clients to sell weapon's ammunition. - * Moreover, when being sold, ammunition cost is always multiplied by 0.75, - * without taking into an account possible discount a player might have. - * This allows cheaters to "print money" by buying and selling ammo over and - * over again ammunition for some weapons, - * notably pipe bombs (74% discount for lvl6 demolition) - * and crossbow (42% discount for lvl6 sharpshooter). - * - * This feature fixes this problem by setting `pickupClass` variable in - * potentially abusable weapons to our own value that won't receive a discount. - * Luckily for us, it seems that pickup spawn and discount checks are the only - * two place where variable is directly checked in a vanilla game's code - * (`default.pickupClass` is used everywhere else), - * so we can easily deal with the side effects of such change. - * Copyright 2020 Anton Tarasenko + * Config object for `FixAmmoSelling_Feature`. + * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -31,347 +17,34 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixAmmoSelling extends Feature +class FixAmmoSelling extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * We will replace `pickupClass` variable for all instances of potentially - * abusable weapons. That is weapons, that have a discount for their ammunition - * (via `GetAmmoCostScaling()` function in a corresponding perk class). - * They are defined (along with our pickup replacements) in `rules` array. - * That array isn't configurable, since the abusable status is hardcoded into - * perk classes and the main mod that allows to change those (ServerPerks), - * also solves ammo selling by a more direct method - * (only available for the mods that replace player pawn class). - * This change already completely fixes ammo printing. - * Possible concern with changing the value of `pickupClass` is that - * it might affect gameplay in too many ways. - * But, luckily for us, that value is only used when spawning a new pickup and - * in `ServerBuyAmmo` function of `KFPawn` - * (all the other places use it's default value instead). - * This means that the only two side-effects of our change are: - * 1. That wrong pickup class will be spawned. This problem is easily - * solved by replacing spawned actor in `CheckReplacement()`. - * 2. That ammo will be sold at a different (lower for us) price, - * while trader would still display and require the original price. - * This problem is solved by manually taking from player the difference - * between what he should have had to pay and what he actually paid. - * This brings us to the second issue - - * detecting when player bought the ammo. - * Unfortunately, it doesn't seem possible to detect with 100% certainty - * without replacing pawn or shop classes, - * so we have to eliminate other possibilities. - * There are seem to be three ways for players to get more ammo: - * 1. For some mod to give it; - * 2. Found it an ammo box; - * 3. To buy ammo (can only happen in trader). - * We don't want to provide mods with low-level API for bug fixes, - * so to ensure the compatibility, mods that want to increase ammo values - * will have to solve compatibility issue by themselves: - * either by reimplementing this fix (possibly the best option) - * or by giving players appropriate money along with the ammo. - * The only other case we have to eliminate is ammo boxes. - * First, all cases of ammo boxes outside the trader are easy to detect, - * since in this case we can be sure that player didn't buy ammo - * (and mods that can allow it can just get rid of - * `ServerSellAmmo()` function directly, similarly to how ServerPerks does it). - * We'll detect all the other boxes by attaching an auxiliary actor - * (`AmmoPickupStalker`) to them, that will fire off `Touch()` event - * at the same time as ammo boxes. - * The only possible problem is that part of the ammo cost is - * taken with a slight delay, which leaves cheaters a window of opportunity - * to buy more than they can afford. - * This issue is addressed by each ammo type costing as little as possible - * (it's cost for corresponding perk at lvl6) - * and a flag that does allow players to go into negative dosh values - * (the cost is potential bugs in this fix itself, that - * can somewhat affect regular players). - */ - -// Due to how this fix works, players with level below 6 get charged less -// than necessary by the shop and this fix must take the rest of -// the cost by itself. -// The problem is, due to how ammo purchase is coded, low-level (<6 lvl) -// players can actually buy more ammo for "fixed" weapons than they can afford -// by filling ammo for one or all weapons. -// Setting this flag to `true` will allow us to still take full cost -// from them, putting them in "debt" (having negative dosh amount). -// If you don't want to have players with negative dosh values on your server -// as a side-effect of this fix, then leave this flag as `false`, -// letting low level players buy ammo cheaper -// (but not cheaper than lvl6 could). -// NOTE: this issue doesn't affect level 6 players. -// NOTE #2: this fix does give players below level 6 some -// technical advantage compared to vanilla game, but this advantage -// cannot exceed benefits of having level 6. -var private config const bool allowNegativeDosh; +var public config bool allowNegativeDosh; -// This structure records what classes of weapons can be abused -// and what pickup class we should use to fix the exploit. -struct ReplacementRule +protected function AssociativeArray ToData() { - var class abusableWeapon; - var class pickupReplacement; -}; - -// Actual list of abusable weapons. -var private const array rules; - -// We create one such record for any -// abusable weapon instance in the game to store: -struct WeaponRecord -{ - // The instance itself. - var KFWeapon weapon; - // Corresponding ammo instance - // (all abusable weapons only have one ammo type). - var KFAmmunition ammo; - // Last ammo amount we've seen, used to detect players gaining ammo - // (from either ammo boxes or buying it). - var int lastAmmoAmount; -}; - -protected function OnEnabled() -{ - local LevelInfo level; - local KFWeapon nextWeapon; - local KFAmmoPickup nextPickup; - level = _.unreal.GetLevel(); - // Find all abusable weapons - foreach level.DynamicActors(class'KFMod.KFWeapon', nextWeapon) { - FixWeapon(nextWeapon); - } - // Start tracking all ammo boxes - foreach level.DynamicActors(class'KFMod.KFAmmoPickup', nextPickup) { - class'AmmoPickupStalker'.static.StalkAmmoPickup(nextPickup); - } + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(P("allowNegativeDosh"), allowNegativeDosh, true); + return data; } -protected function OnDisabled() +protected function FromData(AssociativeArray source) { - local int i; - local LevelInfo level; - local AmmoPickupStalker nextStalker; - local array stalkers; - local array registeredWeapons; - level = _.unreal.GetLevel(); - registeredWeapons = FixAmmoSellingService(GetService()).registeredWeapons; - // Restore all the `pickupClass` variables we've changed. - for (i = 0; i < registeredWeapons.length; i += 1) - { - if (registeredWeapons[i].weapon != none) - { - registeredWeapons[i].weapon.pickupClass = - registeredWeapons[i].weapon.default.pickupClass; - } + if (source != none) { + allowNegativeDosh = source.GetBool(P("allowNegativeDosh")); } - // Kill all the stalkers; - // to be safe, avoid destroying them directly in the iterator. - foreach level.DynamicActors(class'AmmoPickupStalker', nextStalker) { - stalkers[stalkers.length] = nextStalker; - } - for (i = 0; i < stalkers.length; i += 1) - { - if (stalkers[i] != none) { - stalkers[i].Destroy(); - } - } -} - -// Checks if given class is a one of our pickup replacer classes. -public static final function bool IsReplacer(class pickupClass) -{ - local int i; - if (pickupClass == none) return false; - for (i = 0; i < default.rules.length; i += 1) - { - if (pickupClass == default.rules[i].pickupReplacement) { - return true; - } - } - return false; } -// 1. Checks if weapon can be abused and if it can, - fixes the problem. -// 2. Starts tracking abusable weapon to detect when player buys ammo for it. -public final function FixWeapon(KFWeapon potentialAbuser) +protected function DefaultIt() { - local int i; - local WeaponRecord newRecord; - local array registeredWeapons; - local FixAmmoSellingService service; - if (potentialAbuser == none) return; - - service = FixAmmoSellingService(GetService()); - registeredWeapons = service.registeredWeapons; - for (i = 0; i < registeredWeapons.length; i += 1) - { - if (registeredWeapons[i].weapon == potentialAbuser) { - return; - } - } - for (i = 0; i < rules.length; i += 1) - { - if (potentialAbuser.class == rules[i].abusableWeapon) - { - potentialAbuser.pickupClass = rules[i].pickupReplacement; - newRecord.weapon = potentialAbuser; - registeredWeapons[registeredWeapons.length] = newRecord; - service.registeredWeapons = registeredWeapons; - return; - } - } -} - -// Finds ammo instance for recorded weapon in it's owner's inventory. -public final function WeaponRecord FindAmmoInstance(WeaponRecord record) -{ - local Inventory invIter; - local KFAmmunition ammo; - if (record.weapon == none) return record; - if (record.weapon.instigator == none) return record; - - // Find instances anew - invIter = record.weapon.instigator.inventory; - while (invIter != none) - { - if (record.weapon.ammoClass[0] == invIter.class) { - ammo = KFAmmunition(invIter); - } - invIter = invIter.inventory; - } - // Add missing instances - if (ammo != none) - { - record.ammo = ammo; - record.lastAmmoAmount = ammo.ammoAmount; - } - return record; -} - -// Calculates how much more player should have paid for `ammoAmount` -// amount of ammo, compared to how much trader took after our fix. -private final function float GetPriceCorrection( - KFWeapon kfWeapon, - int ammoAmount -) -{ - local float boughtMagFraction; - // `vanillaPrice` - price that would be calculated - // without our interference - // `fixPrice` - price that will be calculated after - // we've replaced pickup class - local float vanillaPrice, fixPrice; - local KFPlayerReplicationInfo kfRI; - local class vanillaPickupClass, fixPickupClass; - if (kfWeapon == none || kfWeapon.instigator == none) return 0.0; - fixPickupClass = class(kfWeapon.pickupClass); - vanillaPickupClass = class(kfWeapon.default.pickupClass); - if (fixPickupClass == none || vanillaPickupClass == none) return 0.0; - - // Calculate base prices - boughtMagFraction = (float(ammoAmount) / kfWeapon.default.magCapacity); - fixPrice = boughtMagFraction * fixPickupClass.default.AmmoCost; - vanillaPrice = boughtMagFraction * vanillaPickupClass.default.AmmoCost; - // Apply perk discount for vanilla price - // (we don't need to consider secondary ammo or husk gun special cases, - // since such weapons can't be abused via ammo dosh-printing) - kfRI = KFPlayerReplicationInfo(kfWeapon.instigator.playerReplicationInfo); - if (kfRI != none && kfRI.clientVeteranSkill != none) - { - vanillaPrice *= kfRI.clientVeteranSkill.static. - GetAmmoCostScaling(kfRI, vanillaPickupClass); - } - // TWI's code rounds up ammo cost - // to the integer value whenever ammo is bought, - // so to calculate exactly how much we need to correct the cost, - // we must find difference between the final, rounded cost values. - return float(Max(0, int(vanillaPrice) - int(fixPrice))); -} - -// Takes current ammo and last recorded in `record` value to calculate -// how much money to take from the player -// (calculations are done via `GetPriceCorrection()`). -public final function WeaponRecord TaxAmmoChange(WeaponRecord record) -{ - local int ammoDiff; - local KFPawn taxPayer; - local PlayerReplicationInfo replicationInfo; - taxPayer = KFPawn(record.weapon.instigator); - if (record.weapon == none || taxPayer == none) return record; - // No need to charge money if player couldn't have - // possibly bought the ammo. - if (!taxPayer.CanBuyNow()) return record; - // Find ammo difference with recorded value. - if (record.ammo != none) - { - ammoDiff = Max(0, record.ammo.ammoAmount - record.lastAmmoAmount); - record.lastAmmoAmount = record.ammo.ammoAmount; - } - // Make player pay dosh - replicationInfo = taxPayer.playerReplicationInfo; - if (replicationInfo != none) - { - replicationInfo.score -= GetPriceCorrection(record.weapon, ammoDiff); - // This shouldn't happen, since shop is supposed to make sure - // player has enough dosh to buy ammo at full price - // (actual price + our correction). - // But if user is extra concerned about it, - - // we can additionally for force the score above 0. - if (!allowNegativeDosh) { - replicationInfo.score = FMax(0, replicationInfo.score); - } - } - return record; -} - -// Changes our records to account for player picking up the ammo box, -// to avoid charging his for it. -public final function RecordAmmoPickup(Pawn pawnWithAmmo, KFAmmoPickup pickup) -{ - local int i; - local int newAmount; - local array registeredWeapons; - local FixAmmoSellingService service; - // Check conditions from `KFAmmoPickup` code (`Touch()` method) - if (pickup == none) return; - if (pawnWithAmmo == none) return; - if (pawnWithAmmo.controller == none) return; - if (!pawnWithAmmo.bCanPickupInventory) return; - if (!pickup.FastTrace(pawnWithAmmo.location, pickup.location)) return; - - // Add relevant amount of ammo to our records - service = FixAmmoSellingService(GetService()); - registeredWeapons = service.registeredWeapons; - for (i = 0; i < registeredWeapons.length; i += 1) - { - if (registeredWeapons[i].weapon == none) continue; - if (registeredWeapons[i].weapon.instigator == pawnWithAmmo) - { - newAmount = registeredWeapons[i].lastAmmoAmount - + registeredWeapons[i].ammo.ammoPickupAmount; - newAmount = Min(registeredWeapons[i].ammo.maxAmmo, newAmount); - registeredWeapons[i].lastAmmoAmount = newAmount; - } - } - service.registeredWeapons = registeredWeapons; + allowNegativeDosh = false; } defaultproperties { + configName = "AcediaFixes" allowNegativeDosh = false - rules(0)=(abusableWeapon=class'KFMod.Crossbow',pickupReplacement=class'FixAmmoSellingClass_CrossbowPickup') - rules(1)=(abusableWeapon=class'KFMod.PipeBombExplosive',pickupReplacement=class'FixAmmoSellingClass_PipeBombPickup') - rules(2)=(abusableWeapon=class'KFMod.M79GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_M79Pickup') - rules(3)=(abusableWeapon=class'KFMod.GoldenM79GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_GoldenM79Pickup') - rules(4)=(abusableWeapon=class'KFMod.M32GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_M32Pickup') - rules(5)=(abusableWeapon=class'KFMod.CamoM32GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_CamoM32Pickup') - rules(6)=(abusableWeapon=class'KFMod.LAW',pickupReplacement=class'FixAmmoSellingClass_LAWPickup') - rules(7)=(abusableWeapon=class'KFMod.SPGrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_SPGrenadePickup') - rules(8)=(abusableWeapon=class'KFMod.SealSquealHarpoonBomber',pickupReplacement=class'FixAmmoSellingClass_SealSquealPickup') - rules(9)=(abusableWeapon=class'KFMod.SeekerSixRocketLauncher',pickupReplacement=class'FixAmmoSellingClass_SeekerSixPickup') - // Listeners - requiredListeners(0) = class'MutatorListener_FixAmmoSelling' - // Service - serviceClass = class'FixAmmoSellingService' } \ No newline at end of file diff --git a/sources/FixAmmoSelling/FixAmmoSellingService.uc b/sources/FixAmmoSelling/FixAmmoSellingService.uc index b8c8857..c6d4ecc 100644 --- a/sources/FixAmmoSelling/FixAmmoSellingService.uc +++ b/sources/FixAmmoSelling/FixAmmoSellingService.uc @@ -18,14 +18,14 @@ * along with Acedia. If not, see . */ class FixAmmoSellingService extends FeatureService - dependson(FixAmmoSelling); + dependson(FixAmmoSelling_Feature); -var private FixAmmoSelling ammoSellingFix; +var private FixAmmoSelling_Feature ammoSellingFix; // All weapons we've detected so far. // Made `public` to avoid needless calls, since this is not part of // a library's interface anyway. -var public array registeredWeapons; +var public array registeredWeapons; protected function Finalizer() { @@ -35,7 +35,7 @@ protected function Finalizer() public function SetOwnerFeature(Feature newOwnerFeature) { super.SetOwnerFeature(newOwnerFeature); - ammoSellingFix = FixAmmoSelling(newOwnerFeature); + ammoSellingFix = FixAmmoSelling_Feature(newOwnerFeature); } event Tick(float delta) diff --git a/sources/FixAmmoSelling/FixAmmoSelling_Feature.uc b/sources/FixAmmoSelling/FixAmmoSelling_Feature.uc new file mode 100644 index 0000000..d76807d --- /dev/null +++ b/sources/FixAmmoSelling/FixAmmoSelling_Feature.uc @@ -0,0 +1,447 @@ +/** + * This feature addressed an oversight in vanilla code that + * allows clients to sell weapon's ammunition. + * Moreover, when being sold, ammunition cost is always multiplied by 0.75, + * without taking into an account possible discount a player might have. + * This allows cheaters to "print money" by buying and selling ammo over and + * over again ammunition for some weapons, + * notably pipe bombs (74% discount for lvl6 demolition) + * and crossbow (42% discount for lvl6 sharpshooter). + * + * This feature fixes this problem by setting `pickupClass` variable in + * potentially abusable weapons to our own value that won't receive a discount. + * Luckily for us, it seems that pickup spawn and discount checks are the only + * two place where variable is directly checked in a vanilla game's code + * (`default.pickupClass` is used everywhere else), + * so we can easily deal with the side effects of such change. + * Copyright 2020 - 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixAmmoSelling_Feature extends Feature; + +/** + * We will replace `pickupClass` variable for all instances of potentially + * abusable weapons. That is weapons, that have a discount for their ammunition + * (via `GetAmmoCostScaling()` function in a corresponding perk class). + * They are defined (along with our pickup replacements) in `rules` array. + * That array isn't configurable, since the abusable status is hardcoded into + * perk classes and the main mod that allows to change those (ServerPerks), + * also solves ammo selling by a more direct method + * (only available for the mods that replace player pawn class). + * This change already completely fixes ammo printing. + * Possible concern with changing the value of `pickupClass` is that + * it might affect gameplay in too many ways. + * But, luckily for us, that value is only used when spawning a new pickup and + * in `ServerBuyAmmo` function of `KFPawn` + * (all the other places use it's default value instead). + * This means that the only two side-effects of our change are: + * 1. That wrong pickup class will be spawned. This problem is easily + * solved by replacing spawned actor in `CheckReplacement()`. + * 2. That ammo will be sold at a different (lower for us) price, + * while trader would still display and require the original price. + * This problem is solved by manually taking from player the difference + * between what he should have had to pay and what he actually paid. + * This brings us to the second issue - + * detecting when player bought the ammo. + * Unfortunately, it doesn't seem possible to detect with 100% certainty + * without replacing pawn or shop classes, + * so we have to eliminate other possibilities. + * There are seem to be three ways for players to get more ammo: + * 1. For some mod to give it; + * 2. Found it an ammo box; + * 3. To buy ammo (can only happen in trader). + * We don't want to provide mods with low-level API for bug fixes, + * so to ensure the compatibility, mods that want to increase ammo values + * will have to solve compatibility issue by themselves: + * either by reimplementing this fix (possibly the best option) + * or by giving players appropriate money along with the ammo. + * The only other case we have to eliminate is ammo boxes. + * First, all cases of ammo boxes outside the trader are easy to detect, + * since in this case we can be sure that player didn't buy ammo + * (and mods that can allow it can just get rid of + * `ServerSellAmmo()` function directly, similarly to how ServerPerks does it). + * We'll detect all the other boxes by attaching an auxiliary actor + * (`AmmoPickupStalker`) to them, that will fire off `Touch()` event + * at the same time as ammo boxes. + * The only possible problem is that part of the ammo cost is + * taken with a slight delay, which leaves cheaters a window of opportunity + * to buy more than they can afford. + * This issue is addressed by each ammo type costing as little as possible + * (it's cost for corresponding perk at lvl6) + * and a flag that does allow players to go into negative dosh values + * (the cost is potential bugs in this fix itself, that + * can somewhat affect regular players). + */ + +// Due to how this fix works, players with level below 6 get charged less +// than necessary by the shop and this fix must take the rest of +// the cost by itself. +// The problem is, due to how ammo purchase is coded, low-level (<6 lvl) +// players can actually buy more ammo for "fixed" weapons than they can afford +// by filling ammo for one or all weapons. +// Setting this flag to `true` will allow us to still take full cost +// from them, putting them in "debt" (having negative dosh amount). +// If you don't want to have players with negative dosh values on your server +// as a side-effect of this fix, then leave this flag as `false`, +// letting low level players buy ammo cheaper +// (but not cheaper than lvl6 could). +// NOTE: this issue doesn't affect level 6 players. +// NOTE #2: this fix does give players below level 6 some +// technical advantage compared to vanilla game, but this advantage +// cannot exceed benefits of having level 6. +var private /*config*/ bool allowNegativeDosh; + +// This structure records what classes of weapons can be abused +// and what pickup class we should use to fix the exploit. +struct ReplacementRule +{ + var class abusableWeapon; + var class pickupReplacement; +}; + +// Actual list of abusable weapons. +var private const array rules; + +// We create one such record for any +// abusable weapon instance in the game to store: +struct WeaponRecord +{ + // The instance itself. + var KFWeapon weapon; + // Corresponding ammo instance + // (all abusable weapons only have one ammo type). + var KFAmmunition ammo; + // Last ammo amount we've seen, used to detect players gaining ammo + // (from either ammo boxes or buying it). + var int lastAmmoAmount; +}; + +protected function OnEnabled() +{ + local LevelInfo level; + local KFWeapon nextWeapon; + local KFAmmoPickup nextPickup; + level = _.unreal.GetLevel(); + _.unreal.mutator.OnCheckReplacement(self).connect = CheckReplacement; + // Find all abusable weapons + foreach level.DynamicActors(class'KFMod.KFWeapon', nextWeapon) { + FixWeapon(nextWeapon); + } + // Start tracking all ammo boxes + foreach level.DynamicActors(class'KFMod.KFAmmoPickup', nextPickup) { + class'AmmoPickupStalker'.static.StalkAmmoPickup(nextPickup); + } +} + +protected function OnDisabled() +{ + local int i; + local LevelInfo level; + local AmmoPickupStalker nextStalker; + local array stalkers; + local array registeredWeapons; + level = _.unreal.GetLevel(); + _.unreal.mutator.OnCheckReplacement(self).Disconnect(); + registeredWeapons = FixAmmoSellingService(GetService()).registeredWeapons; + // Restore all the `pickupClass` variables we've changed. + for (i = 0; i < registeredWeapons.length; i += 1) + { + if (registeredWeapons[i].weapon != none) + { + registeredWeapons[i].weapon.pickupClass = + registeredWeapons[i].weapon.default.pickupClass; + } + } + // Kill all the stalkers; + // to be safe, avoid destroying them directly in the iterator. + foreach level.DynamicActors(class'AmmoPickupStalker', nextStalker) { + stalkers[stalkers.length] = nextStalker; + } + for (i = 0; i < stalkers.length; i += 1) + { + if (stalkers[i] != none) { + stalkers[i].Destroy(); + } + } +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixAmmoSelling newConfig; + newConfig = FixAmmoSelling(config); + if (newConfig == none) { + return; + } + allowNegativeDosh = newConfig.allowNegativeDosh; +} + +// Checks if given class is a one of our pickup replacer classes. +public static final function bool IsReplacer(class pickupClass) +{ + local int i; + if (pickupClass == none) return false; + for (i = 0; i < default.rules.length; i += 1) + { + if (pickupClass == default.rules[i].pickupReplacement) { + return true; + } + } + return false; +} + +// 1. Checks if weapon can be abused and if it can, - fixes the problem. +// 2. Starts tracking abusable weapon to detect when player buys ammo for it. +public final function FixWeapon(KFWeapon potentialAbuser) +{ + local int i; + local WeaponRecord newRecord; + local array registeredWeapons; + local FixAmmoSellingService service; + if (potentialAbuser == none) return; + + service = FixAmmoSellingService(GetService()); + registeredWeapons = service.registeredWeapons; + for (i = 0; i < registeredWeapons.length; i += 1) + { + if (registeredWeapons[i].weapon == potentialAbuser) { + return; + } + } + for (i = 0; i < rules.length; i += 1) + { + if (potentialAbuser.class == rules[i].abusableWeapon) + { + potentialAbuser.pickupClass = rules[i].pickupReplacement; + newRecord.weapon = potentialAbuser; + registeredWeapons[registeredWeapons.length] = newRecord; + service.registeredWeapons = registeredWeapons; + return; + } + } +} + +// Finds ammo instance for recorded weapon in it's owner's inventory. +public final function WeaponRecord FindAmmoInstance(WeaponRecord record) +{ + local Inventory invIter; + local KFAmmunition ammo; + if (record.weapon == none) return record; + if (record.weapon.instigator == none) return record; + + // Find instances anew + invIter = record.weapon.instigator.inventory; + while (invIter != none) + { + if (record.weapon.ammoClass[0] == invIter.class) { + ammo = KFAmmunition(invIter); + } + invIter = invIter.inventory; + } + // Add missing instances + if (ammo != none) + { + record.ammo = ammo; + record.lastAmmoAmount = ammo.ammoAmount; + } + return record; +} + +// Calculates how much more player should have paid for `ammoAmount` +// amount of ammo, compared to how much trader took after our fix. +private final function float GetPriceCorrection( + KFWeapon kfWeapon, + int ammoAmount +) +{ + local float boughtMagFraction; + // `vanillaPrice` - price that would be calculated + // without our interference + // `fixPrice` - price that will be calculated after + // we've replaced pickup class + local float vanillaPrice, fixPrice; + local KFPlayerReplicationInfo kfRI; + local class vanillaPickupClass, fixPickupClass; + if (kfWeapon == none || kfWeapon.instigator == none) return 0.0; + fixPickupClass = class(kfWeapon.pickupClass); + vanillaPickupClass = class(kfWeapon.default.pickupClass); + if (fixPickupClass == none || vanillaPickupClass == none) return 0.0; + + // Calculate base prices + boughtMagFraction = (float(ammoAmount) / kfWeapon.default.magCapacity); + fixPrice = boughtMagFraction * fixPickupClass.default.AmmoCost; + vanillaPrice = boughtMagFraction * vanillaPickupClass.default.AmmoCost; + // Apply perk discount for vanilla price + // (we don't need to consider secondary ammo or husk gun special cases, + // since such weapons can't be abused via ammo dosh-printing) + kfRI = KFPlayerReplicationInfo(kfWeapon.instigator.playerReplicationInfo); + if (kfRI != none && kfRI.clientVeteranSkill != none) + { + vanillaPrice *= kfRI.clientVeteranSkill.static. + GetAmmoCostScaling(kfRI, vanillaPickupClass); + } + // TWI's code rounds up ammo cost + // to the integer value whenever ammo is bought, + // so to calculate exactly how much we need to correct the cost, + // we must find difference between the final, rounded cost values. + return float(Max(0, int(vanillaPrice) - int(fixPrice))); +} + +// Takes current ammo and last recorded in `record` value to calculate +// how much money to take from the player +// (calculations are done via `GetPriceCorrection()`). +public final function WeaponRecord TaxAmmoChange(WeaponRecord record) +{ + local int ammoDiff; + local KFPawn taxPayer; + local PlayerReplicationInfo replicationInfo; + taxPayer = KFPawn(record.weapon.instigator); + if (record.weapon == none || taxPayer == none) return record; + // No need to charge money if player couldn't have + // possibly bought the ammo. + if (!taxPayer.CanBuyNow()) return record; + // Find ammo difference with recorded value. + if (record.ammo != none) + { + ammoDiff = Max(0, record.ammo.ammoAmount - record.lastAmmoAmount); + record.lastAmmoAmount = record.ammo.ammoAmount; + } + // Make player pay dosh + replicationInfo = taxPayer.playerReplicationInfo; + if (replicationInfo != none) + { + replicationInfo.score -= GetPriceCorrection(record.weapon, ammoDiff); + // This shouldn't happen, since shop is supposed to make sure + // player has enough dosh to buy ammo at full price + // (actual price + our correction). + // But if user is extra concerned about it, - + // we can additionally for force the score above 0. + if (!allowNegativeDosh) { + replicationInfo.score = FMax(0, replicationInfo.score); + } + } + return record; +} + +// Changes our records to account for player picking up the ammo box, +// to avoid charging his for it. +public final function RecordAmmoPickup(Pawn pawnWithAmmo, KFAmmoPickup pickup) +{ + local int i; + local int newAmount; + local array registeredWeapons; + local FixAmmoSellingService service; + // Check conditions from `KFAmmoPickup` code (`Touch()` method) + if (pickup == none) return; + if (pawnWithAmmo == none) return; + if (pawnWithAmmo.controller == none) return; + if (!pawnWithAmmo.bCanPickupInventory) return; + if (!pickup.FastTrace(pawnWithAmmo.location, pickup.location)) return; + + // Add relevant amount of ammo to our records + service = FixAmmoSellingService(GetService()); + registeredWeapons = service.registeredWeapons; + for (i = 0; i < registeredWeapons.length; i += 1) + { + if (registeredWeapons[i].weapon == none) continue; + if (registeredWeapons[i].weapon.instigator == pawnWithAmmo) + { + newAmount = registeredWeapons[i].lastAmmoAmount + + registeredWeapons[i].ammo.ammoPickupAmount; + newAmount = Min(registeredWeapons[i].ammo.maxAmmo, newAmount); + registeredWeapons[i].lastAmmoAmount = newAmount; + } + } + service.registeredWeapons = registeredWeapons; +} + +private final function bool CheckReplacement( + Actor other, + out byte isSuperRelevant) +{ + if (other == none) { + return true; + } + // We need to replace pickup classes back, + // as they might not even exist on clients. + if (IsReplacer(other.class)) + { + ReplaceOldPickup(Pickup(other)); + return false; + } + FixWeapon(KFWeapon(other)); + // If it's ammo pickup - we need to stalk it + class'AmmoPickupStalker'.static.StalkAmmoPickup(KFAmmoPickup(other)); + return true; +} + +// This function recreates the logic of `KFWeapon.DropFrom()`, +// since standard `ReplaceWith()` method produces bad results. +private function ReplaceOldPickup(Pickup oldPickup) +{ + local Pawn instigator; + local Pickup newPickup; + local KFWeapon relevantWeapon; + if (oldPickup == none) return; + instigator = oldPickup.instigator; + if (instigator == none) return; + relevantWeapon = GetWeaponOfClass(instigator, oldPickup.inventoryType); + if (relevantWeapon == none) return; + + newPickup = relevantWeapon.Spawn( relevantWeapon.default.pickupClass,,, + relevantWeapon.location); + newPickup.InitDroppedPickupFor(relevantWeapon); + newPickup.velocity = relevantWeapon.velocity + + Vector(instigator.rotation) * 100; + if (instigator.health > 0) + KFWeaponPickup(newPickup).bThrown = true; +} + +static final function KFWeapon GetWeaponOfClass( + Pawn playerPawn, + class weaponClass) +{ + local Inventory invIter; + if (playerPawn == none) return none; + + invIter = playerPawn.inventory; + while (invIter != none) + { + if (invIter.class == weaponClass) { + return KFWeapon(invIter); + } + invIter = invIter.inventory; + } + return none; +} + +defaultproperties +{ + configClass = class'FixAmmoSelling' + allowNegativeDosh = false + rules(0)=(abusableWeapon=class'KFMod.Crossbow',pickupReplacement=class'FixAmmoSellingClass_CrossbowPickup') + rules(1)=(abusableWeapon=class'KFMod.PipeBombExplosive',pickupReplacement=class'FixAmmoSellingClass_PipeBombPickup') + rules(2)=(abusableWeapon=class'KFMod.M79GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_M79Pickup') + rules(3)=(abusableWeapon=class'KFMod.GoldenM79GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_GoldenM79Pickup') + rules(4)=(abusableWeapon=class'KFMod.M32GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_M32Pickup') + rules(5)=(abusableWeapon=class'KFMod.CamoM32GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_CamoM32Pickup') + rules(6)=(abusableWeapon=class'KFMod.LAW',pickupReplacement=class'FixAmmoSellingClass_LAWPickup') + rules(7)=(abusableWeapon=class'KFMod.SPGrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_SPGrenadePickup') + rules(8)=(abusableWeapon=class'KFMod.SealSquealHarpoonBomber',pickupReplacement=class'FixAmmoSellingClass_SealSquealPickup') + rules(9)=(abusableWeapon=class'KFMod.SeekerSixRocketLauncher',pickupReplacement=class'FixAmmoSellingClass_SeekerSixPickup') + // Service + serviceClass = class'FixAmmoSellingService' +} \ No newline at end of file diff --git a/sources/FixAmmoSelling/MutatorListener_FixAmmoSelling.uc b/sources/FixAmmoSelling/MutatorListener_FixAmmoSelling.uc deleted file mode 100644 index 0c7a528..0000000 --- a/sources/FixAmmoSelling/MutatorListener_FixAmmoSelling.uc +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Overloaded mutator events listener to register every new - * spawned weapon and ammo pickup. - * Copyright 2020 Anton Tarasenko - *------------------------------------------------------------------------------ - * This file is part of Acedia. - * - * Acedia is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Acedia is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Acedia. If not, see . - */ -class MutatorListener_FixAmmoSelling extends MutatorListenerBase - abstract; - -static function bool CheckReplacement(Actor other, out byte isSuperRelevant) -{ - if (other == none) return true; - - // We need to replace pickup classes back, - // as they might not even exist on clients. - if (class'FixAmmoSelling'.static.IsReplacer(other.class)) - { - ReplaceOldPickup(Pickup(other)); - return false; - } - CheckAbusableWeapon(KFWeapon(other)); - // If it's ammo pickup - we need to stalk it - class'AmmoPickupStalker'.static.StalkAmmoPickup(KFAmmoPickup(other)); - return true; -} - -private static function CheckAbusableWeapon(KFWeapon newWeapon) -{ - local FixAmmoSelling ammoSellingFix; - if (newWeapon == none) return; - ammoSellingFix = FixAmmoSelling(class'FixAmmoSelling'.static.GetInstance()); - if (ammoSellingFix == none) return; - ammoSellingFix.FixWeapon(newWeapon); -} - -// This function recreates the logic of `KFWeapon.DropFrom()`, -// since standard `ReplaceWith()` method produces bad results. -private static function ReplaceOldPickup(Pickup oldPickup) -{ - local Pawn instigator; - local Pickup newPickup; - local KFWeapon relevantWeapon; - if (oldPickup == none) return; - instigator = oldPickup.instigator; - if (instigator == none) return; - relevantWeapon = GetWeaponOfClass(instigator, oldPickup.inventoryType); - if (relevantWeapon == none) return; - - newPickup = relevantWeapon.Spawn( relevantWeapon.default.pickupClass,,, - relevantWeapon.location); - newPickup.InitDroppedPickupFor(relevantWeapon); - newPickup.velocity = relevantWeapon.velocity + - Vector(instigator.rotation) * 100; - if (instigator.health > 0) - KFWeaponPickup(newPickup).bThrown = true; -} - -static final function KFWeapon GetWeaponOfClass( - Pawn playerPawn, - class weaponClass -) -{ - local Inventory invIter; - if (playerPawn == none) return none; - - invIter = playerPawn.inventory; - while (invIter != none) - { - if (invIter.class == weaponClass) { - return KFWeapon(invIter); - } - invIter = invIter.inventory; - } - return none; -} - -defaultproperties -{ - relatedEvents = class'MutatorEvents' -} \ No newline at end of file diff --git a/sources/FixDoshSpam/FixDoshSpam.uc b/sources/FixDoshSpam/FixDoshSpam.uc index 73b9a84..9a55cc8 100644 --- a/sources/FixDoshSpam/FixDoshSpam.uc +++ b/sources/FixDoshSpam/FixDoshSpam.uc @@ -1,14 +1,6 @@ /** - * This feature addressed two dosh-related issues: - * 1. Crashing servers by spamming 'CashPickup' actors with 'TossCash'; - * 2. Breaking collision detection logic by stacking large amount of - * 'CashPickup' actors in one place, which allows one to either - * reach unintended locations or even instantly kill zeds. - * - * It fixes them by limiting speed, with which dosh can spawn, and - * allowing this limit to decrease when there's already too much dosh - * present on the map. - * Copyright 2019 - 2021 Anton Tarasenko + * Config object for `FixDoshSpam_Feature`. + * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -25,235 +17,50 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixDoshSpam extends Feature +class FixDoshSpam extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * First, we limit amount of dosh that can be spawned simultaneously. - * The simplest method is to place a cooldown on spawning `CashPickup` actors, - * i.e. after spawning one `CashPickup` we'd completely prevent spawning - * any other instances of it for a fixed amount of time. - * However, that might allow a malicious spammer to block others from - * throwing dosh, - all he needs to do is to spam dosh at right time intervals. - * We'll resolve this issue by recording how many `CashPickup` actors - * each player has spawned as their "contribution" and decay - * that value with time, only allowing to spawn new dosh after - * contribution decayed to zero. Speed of decay is derived from current dosh - * spawning speed limit and decreases with amount of players - * with non-zero contributions (since it means that they're throwing dosh). - * Second issue is player amassing a large amount of dosh in one point - * that leads to skipping collision checks, which then allows players to pass - * through level geometry or enter zeds' collisions, instantly killing them. - * Since dosh disappears on it's own, the easiest method to prevent that is to - * severely limit how much dosh players can throw per second, - * so that there's never enough dosh laying around to affect collision logic. - * The downside to such severe limitations is that game behaves less - * vanilla-like, where you could throw away streams of dosh. - * To solve that we'll first use a more generous limit on dosh players can - * throw per second, but will track how much dosh is currently present - * in a level and linearly decelerate speed, according to that amount. - */ - -// Highest and lowest speed with which players can throw dosh wads. -// It'll be evenly spread between all players. -// For example, if speed is set to 6 and only one player will be spamming dosh, -// - he'll be able to throw 6 wads of dosh per second; -// but if all 6 players are spamming it, - each will throw only 1 per second. -// NOTE: these speed values can be exceeded, since a player is guaranteed -// to be able to throw at least one wad of dosh, if he didn't do so in awhile. -// NOTE #2: if maximum value is less than minimum one, -// the lowest (maximum one) will be used. -var private config const float doshPerSecondLimitMax; -var private config const float doshPerSecondLimitMin; -// Amount of dosh pickups on the map at which we must set dosh per second -// to `doshPerSecondLimitMin`. -// We use `doshPerSecondLimitMax` when there's no dosh on the map and -// scale linearly between them as it's amount grows. -var private config const int criticalDoshAmount; - -// We immediately reduce the rate dosh can be spawned at when players throw -// new wads of cash. But, for performance reasons, we only periodically turn it -// back up. This interval determines how often we check for whether it's okay -// to raise the limit on the spawned dosh. -// You should not set this value too high, it is recommended not to exceed -// 1 second. -var private config const float checkInterval; - -// This structure records how much a certain player has -// contributed to an overall dosh creation. -struct DoshStreamPerPlayer -{ - // Reference to `PlayerController` - var NativeActorRef player; - // Amount of dosh we remember this player creating, decays with time. - var float contribution; -}; -var private array currentContributors; - -// Wads of cash that are lying around on the map. -var private array wads; - -// Generates "reset" events when `wads` array is getting cleaned from -// destroyed/picked up dosh and players' contributions are reduced. -var private RealTimer checkTimer; +var public /*config*/ float doshPerSecondLimitMax; +var public /*config*/ float doshPerSecondLimitMin; +var public /*config*/ int criticalDoshAmount; +var public /*config*/ float checkInterval; -protected function OnEnabled() +protected function AssociativeArray ToData() { - local LevelInfo level; - local CashPickup nextCash; - checkTimer = _.time.StartRealTimer(checkInterval, true); - checkTimer.OnElapsed(self).connect = Tick; - level = _.unreal.GetLevel(); - // Find all wads of cash laying around on the map, - // so that we could accordingly limit the cash spam. - foreach level.DynamicActors(class'KFMod.CashPickup', nextCash) { - wads[wads.length] = _.unreal.ActorRef(nextCash); - } -} - -protected function OnDisabled() -{ - local int i; - _.memory.FreeMany(wads); - for (i = 0; i < currentContributors.length; i += 1) { - currentContributors[i].player.FreeSelf(); - } - wads.length = 0; - currentContributors.length = 0; - checkTimer.FreeSelf(); -} - -// Did player with this controller contribute to the latest dosh generation? -public final function bool IsContributor(PlayerController player) -{ - return (GetContributorIndex(player) >= 0); -} - -// Did we already reach allowed limit of dosh per second? -public final function bool IsDoshStreamOverLimit() -{ - local int i; - local float overallContribution; - local float allowedContribution; - overallContribution = 0.0; - for (i = 0; i < currentContributors.length; i += 1) { - overallContribution += currentContributors[i].contribution; - } - allowedContribution = checkTimer.GetElapsedTime() * GetCurrentDPSLimit(); - return overallContribution > allowedContribution; -} - -// What is our current dosh per second limit? -private final function float GetCurrentDPSLimit() -{ - local float speedScale; - if (doshPerSecondLimitMax < doshPerSecondLimitMin) { - return doshPerSecondLimitMax; - } - speedScale = Float(wads.length) / Float(criticalDoshAmount); - speedScale = FClamp(speedScale, 0.0, 1.0); - // At 0.0 scale (no dosh on the map) - use max speed - // At 1.0 scale (critical dosh on the map) - use min speed - return Lerp(speedScale, doshPerSecondLimitMax, doshPerSecondLimitMin); -} - -// Returns index of the contributor corresponding to the given controller. -// Returns `-1` if no connection correspond to the given controller. -// Returns `-1` if given controller is equal to `none`. -private final function int GetContributorIndex(PlayerController player) -{ - local int i; - if (player == none) return -1; - - for (i = 0; i < currentContributors.length; i += 1) - { - if (currentContributors[i].player.Get() == player) { - return i; - } - } - return -1; -} - -// Adds given cash to given player contribution record and -// registers that cash in our wads array. -public final function AddContribution(PlayerController player, CashPickup cash) -{ - local int playerIndex; - local DoshStreamPerPlayer newStreamRecord; - wads[wads.length] = _.unreal.ActorRef(cash); - // Add contribution to player - playerIndex = GetContributorIndex(player); - if (playerIndex >= 0) - { - currentContributors[playerIndex].contribution += 1.0; - return; - } - newStreamRecord.player = _.unreal.ActorRef(player); - newStreamRecord.contribution = 1.0; - currentContributors[currentContributors.length] = newStreamRecord; -} - -private final function ReducePlayerContributions() -{ - local int i; - local float streamReduction; - streamReduction = checkInterval * - (GetCurrentDPSLimit() / currentContributors.length); - for (i = 0; i < currentContributors.length; i += 1) { - currentContributors[i].contribution -= streamReduction; - } -} - -// Clean out wads that disappeared or were picked up by players. -private final function CleanWadsArray() -{ - local int i; - i = 0; - while (i < wads.length) - { - if (wads[i].Get() == none) - { - wads[i].FreeSelf(); - wads.Remove(i, 1); - } - else { - i += 1; - } - } + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetFloat(P("doshPerSecondLimitMax"), doshPerSecondLimitMax, true); + data.SetFloat(P("doshPerSecondLimitMin"), doshPerSecondLimitMin, true); + data.SetInt(P("criticalDoshAmount"), criticalDoshAmount, true); + data.SetFloat(P("checkInterval"), checkInterval, true); + return data; } -// Don't track players that no longer contribute to dosh generation. -private final function RemoveNonContributors() +protected function FromData(AssociativeArray source) { - local int i; - local array updContributors; - for (i = 0; i < currentContributors.length; i += 1) + if (source != none) { - // We want to keep on record even players that quit, - // since their contribution still must be accounted for. - if (currentContributors[i].contribution <= 0.0) { - currentContributors[i].player.FreeSelf(); - } - else { - updContributors[updContributors.length] = currentContributors[i]; - } + doshPerSecondLimitMax = source.GetFloat(P("doshPerSecondLimitMax"), 50); + doshPerSecondLimitMin = source.GetFloat(P("doshPerSecondLimitMin"), 5); + criticalDoshAmount = source.GetInt(P("criticalDoshAmount"), 25); + checkInterval = source.GetFloat(P("checkInterval"), 0.25); } - currentContributors = updContributors; } -private function Tick(Timer source) +protected function DefaultIt() { - CleanWadsArray(); - ReducePlayerContributions(); - RemoveNonContributors(); + doshPerSecondLimitMax = 50; + doshPerSecondLimitMin = 5; + criticalDoshAmount = 25; + checkInterval = 0.25; } defaultproperties { + configName = "AcediaFixes" doshPerSecondLimitMax = 50 doshPerSecondLimitMin = 5 criticalDoshAmount = 25 checkInterval = 0.25 - // Listeners - requiredListeners(0) = class'MutatorListener_FixDoshSpam' } \ No newline at end of file diff --git a/sources/FixDoshSpam/FixDoshSpam_Feature.uc b/sources/FixDoshSpam/FixDoshSpam_Feature.uc new file mode 100644 index 0000000..fc12266 --- /dev/null +++ b/sources/FixDoshSpam/FixDoshSpam_Feature.uc @@ -0,0 +1,298 @@ +/** + * This feature addressed two dosh-related issues: + * 1. Crashing servers by spamming 'CashPickup' actors with 'TossCash'; + * 2. Breaking collision detection logic by stacking large amount of + * 'CashPickup' actors in one place, which allows one to either + * reach unintended locations or even instantly kill zeds. + * + * It fixes them by limiting speed, with which dosh can spawn, and + * allowing this limit to decrease when there's already too much dosh + * present on the map. + * Copyright 2019 - 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixDoshSpam_Feature extends Feature; + +/** + * First, we limit amount of dosh that can be spawned simultaneously. + * The simplest method is to place a cooldown on spawning `CashPickup` actors, + * i.e. after spawning one `CashPickup` we'd completely prevent spawning + * any other instances of it for a fixed amount of time. + * However, that might allow a malicious spammer to block others from + * throwing dosh, - all he needs to do is to spam dosh at right time intervals. + * We'll resolve this issue by recording how many `CashPickup` actors + * each player has spawned as their "contribution" and decay + * that value with time, only allowing to spawn new dosh after + * contribution decayed to zero. Speed of decay is derived from current dosh + * spawning speed limit and decreases with amount of players + * with non-zero contributions (since it means that they're throwing dosh). + * Second issue is player amassing a large amount of dosh in one point + * that leads to skipping collision checks, which then allows players to pass + * through level geometry or enter zeds' collisions, instantly killing them. + * Since dosh disappears on it's own, the easiest method to prevent that is to + * severely limit how much dosh players can throw per second, + * so that there's never enough dosh laying around to affect collision logic. + * The downside to such severe limitations is that game behaves less + * vanilla-like, where you could throw away streams of dosh. + * To solve that we'll first use a more generous limit on dosh players can + * throw per second, but will track how much dosh is currently present + * in a level and linearly decelerate speed, according to that amount. + */ + +// Highest and lowest speed with which players can throw dosh wads. +// It'll be evenly spread between all players. +// For example, if speed is set to 6 and only one player will be spamming dosh, +// - he'll be able to throw 6 wads of dosh per second; +// but if all 6 players are spamming it, - each will throw only 1 per second. +// NOTE: these speed values can be exceeded, since a player is guaranteed +// to be able to throw at least one wad of dosh, if he didn't do so in awhile. +// NOTE #2: if maximum value is less than minimum one, +// the lowest (maximum one) will be used. +var private /*config*/ float doshPerSecondLimitMax; +var private /*config*/ float doshPerSecondLimitMin; +// Amount of dosh pickups on the map at which we must set dosh per second +// to `doshPerSecondLimitMin`. +// We use `doshPerSecondLimitMax` when there's no dosh on the map and +// scale linearly between them as it's amount grows. +var private /*config*/ int criticalDoshAmount; + +// We immediately reduce the rate dosh can be spawned at when players throw +// new wads of cash. But, for performance reasons, we only periodically turn it +// back up. This interval determines how often we check for whether it's okay +// to raise the limit on the spawned dosh. +// You should not set this value too high, it is recommended not to exceed +// 1 second. +var private /*config*/ float checkInterval; + +// This structure records how much a certain player has +// contributed to an overall dosh creation. +struct DoshStreamPerPlayer +{ + // Reference to `PlayerController` + var NativeActorRef player; + // Amount of dosh we remember this player creating, decays with time. + var float contribution; +}; +var private array currentContributors; + +// Wads of cash that are lying around on the map. +var private array wads; + +// Generates "reset" events when `wads` array is getting cleaned from +// destroyed/picked up dosh and players' contributions are reduced. +var private RealTimer checkTimer; + +protected function OnEnabled() +{ + local LevelInfo level; + local CashPickup nextCash; + checkTimer = _.time.StartRealTimer(checkInterval, true); + checkTimer.OnElapsed(self).connect = Tick; + _.unreal.mutator.OnCheckReplacement(self).connect = CheckReplacement; + level = _.unreal.GetLevel(); + // Find all wads of cash laying around on the map, + // so that we could accordingly limit the cash spam. + foreach level.DynamicActors(class'KFMod.CashPickup', nextCash) { + wads[wads.length] = _.unreal.ActorRef(nextCash); + } +} + +protected function OnDisabled() +{ + local int i; + _.memory.FreeMany(wads); + for (i = 0; i < currentContributors.length; i += 1) { + currentContributors[i].player.FreeSelf(); + } + wads.length = 0; + currentContributors.length = 0; + _.unreal.mutator.OnCheckReplacement(self).Disconnect(); + checkTimer.FreeSelf(); +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixDoshSpam newConfig; + newConfig = FixDoshSpam(config); + if (newConfig == none) { + return; + } + doshPerSecondLimitMax = newConfig.doshPerSecondLimitMax; + doshPerSecondLimitMin = newConfig.doshPerSecondLimitMin; + criticalDoshAmount = newConfig.criticalDoshAmount; + checkInterval = newConfig.checkInterval; + if (checkTimer != none) { + checkTimer.SetInterval(checkInterval); + } +} + +private final function bool CheckReplacement( + Actor other, + out byte isSuperRelevant) +{ + local PlayerController player; + if (other == none) return true; + if (other.class != class'CashPickup') return true; + // This means this dosh wasn't spawned in `TossCash()` of `KFPawn`, + // so it isn't related to the exploit we're trying to fix. + if (other.instigator == none) return true; + + // We only want to prevent spawning cash if we're already over + // the limit and the one trying to throw this cash contributed to it. + // We allow other players to throw at least one wad of cash. + player = PlayerController(other.instigator.controller); + if (IsDoshStreamOverLimit() && IsContributor(player)) { + return false; + } + // If we do spawn cash - record this contribution. + AddContribution(player, CashPickup(other)); + return true; +} + +// Did player with this controller contribute to the latest dosh generation? +public final function bool IsContributor(PlayerController player) +{ + return (GetContributorIndex(player) >= 0); +} + +// Did we already reach allowed limit of dosh per second? +public final function bool IsDoshStreamOverLimit() +{ + local int i; + local float overallContribution; + local float allowedContribution; + overallContribution = 0.0; + for (i = 0; i < currentContributors.length; i += 1) { + overallContribution += currentContributors[i].contribution; + } + allowedContribution = checkTimer.GetElapsedTime() * GetCurrentDPSLimit(); + return overallContribution > allowedContribution; +} + +// What is our current dosh per second limit? +private final function float GetCurrentDPSLimit() +{ + local float speedScale; + if (doshPerSecondLimitMax < doshPerSecondLimitMin) { + return doshPerSecondLimitMax; + } + speedScale = Float(wads.length) / Float(criticalDoshAmount); + speedScale = FClamp(speedScale, 0.0, 1.0); + // At 0.0 scale (no dosh on the map) - use max speed + // At 1.0 scale (critical dosh on the map) - use min speed + return Lerp(speedScale, doshPerSecondLimitMax, doshPerSecondLimitMin); +} + +// Returns index of the contributor corresponding to the given controller. +// Returns `-1` if no connection correspond to the given controller. +// Returns `-1` if given controller is equal to `none`. +private final function int GetContributorIndex(PlayerController player) +{ + local int i; + if (player == none) return -1; + + for (i = 0; i < currentContributors.length; i += 1) + { + if (currentContributors[i].player.Get() == player) { + return i; + } + } + return -1; +} + +// Adds given cash to given player contribution record and +// registers that cash in our wads array. +public final function AddContribution(PlayerController player, CashPickup cash) +{ + local int playerIndex; + local DoshStreamPerPlayer newStreamRecord; + wads[wads.length] = _.unreal.ActorRef(cash); + // Add contribution to player + playerIndex = GetContributorIndex(player); + if (playerIndex >= 0) + { + currentContributors[playerIndex].contribution += 1.0; + return; + } + newStreamRecord.player = _.unreal.ActorRef(player); + newStreamRecord.contribution = 1.0; + currentContributors[currentContributors.length] = newStreamRecord; +} + +private final function ReducePlayerContributions() +{ + local int i; + local float streamReduction; + streamReduction = checkInterval * + (GetCurrentDPSLimit() / currentContributors.length); + for (i = 0; i < currentContributors.length; i += 1) { + currentContributors[i].contribution -= streamReduction; + } +} + +// Clean out wads that disappeared or were picked up by players. +private final function CleanWadsArray() +{ + local int i; + i = 0; + while (i < wads.length) + { + if (wads[i].Get() == none) + { + wads[i].FreeSelf(); + wads.Remove(i, 1); + } + else { + i += 1; + } + } +} + +// Don't track players that no longer contribute to dosh generation. +private final function RemoveNonContributors() +{ + local int i; + local array updContributors; + for (i = 0; i < currentContributors.length; i += 1) + { + // We want to keep on record even players that quit, + // since their contribution still must be accounted for. + if (currentContributors[i].contribution <= 0.0) { + currentContributors[i].player.FreeSelf(); + } + else { + updContributors[updContributors.length] = currentContributors[i]; + } + } + currentContributors = updContributors; +} + +private function Tick(Timer source) +{ + CleanWadsArray(); + ReducePlayerContributions(); + RemoveNonContributors(); +} + +defaultproperties +{ + configClass = class'FixDoshSpam' + doshPerSecondLimitMax = 50 + doshPerSecondLimitMin = 5 + criticalDoshAmount = 25 + checkInterval = 0.25 +} \ No newline at end of file diff --git a/sources/FixDoshSpam/MutatorListener_FixDoshSpam.uc b/sources/FixDoshSpam/MutatorListener_FixDoshSpam.uc deleted file mode 100644 index b173f0a..0000000 --- a/sources/FixDoshSpam/MutatorListener_FixDoshSpam.uc +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Overloaded mutator events listener to catch and, possibly, - * prevent spawning dosh actors. - * Copyright 2019 - 2021 Anton Tarasenko - *------------------------------------------------------------------------------ - * This file is part of Acedia. - * - * Acedia is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Acedia is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Acedia. If not, see . - */ -class MutatorListener_FixDoshSpam extends MutatorListenerBase - abstract; - -static function bool CheckReplacement(Actor other, out byte isSuperRelevant) -{ - local FixDoshSpam doshFix; - local PlayerController player; - if (other == none) return true; - if (other.class != class'CashPickup') return true; - // This means this dosh wasn't spawned in `TossCash()` of `KFPawn`, - // so it isn't related to the exploit we're trying to fix. - if (other.instigator == none) return true; - doshFix = FixDoshSpam(class'FixDoshSpam'.static.GetInstance()); - if (doshFix == none) return true; - - // We only want to prevent spawning cash if we're already over - // the limit and the one trying to throw this cash contributed to it. - // We allow other players to throw at least one wad of cash. - player = PlayerController(other.instigator.controller); - if (doshFix.IsDoshStreamOverLimit() && doshFix.IsContributor(player)) { - return false; - } - // If we do spawn cash - record this contribution. - doshFix.AddContribution(player, CashPickup(other)); - return true; -} - -defaultproperties -{ - relatedEvents = class'MutatorEvents' -} \ No newline at end of file diff --git a/sources/FixDualiesCost/FixDualiesCost.uc b/sources/FixDualiesCost/FixDualiesCost.uc index 93da39c..1c913cd 100644 --- a/sources/FixDualiesCost/FixDualiesCost.uc +++ b/sources/FixDualiesCost/FixDualiesCost.uc @@ -1,15 +1,6 @@ /** - * This feature fixes several issues related to the selling price of both - * single and dual pistols, all originating from the existence of dual weapons. - * Most notable issue is the ability to "print" money by buying and - * selling pistols in a certain way. - * - * It fixes all of the issues by manually setting pistols' - * `SellValue` variables to proper values. - * Fix only works with vanilla pistols, as it's unpredictable what - * custom ones can do and they can handle these issues on their own - * in a better way. - * Copyright 2020 - 2021 Anton Tarasenko + * Config object for `FixDualiesCost_Feature`. + * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -26,434 +17,36 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixDualiesCost extends Feature +class FixDualiesCost extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * Issues with pistols' cost may look varied and surface in - * a plethora of ways, but all of them originate from the two main errors - * in vanilla's code: - * 1. If you have a pistol in your inventory at the time when you - * buy/pickup another one - the sell value of resulting dualies is - * incorrectly set to the sell value of the second pistol; - * 2. When player has dual pistols and drops one on the floor, - - * the sell value for the one left with the player isn't set. - * All weapons in Killing Floor get sell value assigned to them - * (appropriately, in a `SellValue` variable). This is to ensure that the sell - * price is set the moment players buys the gun. Otherwise, due to ridiculous - * perked discounts, you'd be able to buy a pistol at 30% price - * as sharpshooter, but sell at 75% of a price as any other perk, - * resulting in 45% of pure profit. - * Unfortunately, that's exactly what happens when `SellValue` isn't set - * (left as it's default value of `-1`): sell value of such weapons is - * determined only at the moment of sale and depends on the perk of the seller, - * allowing for possible exploits. - * - * These issues are fixed by directly assigning - * proper values to `SellValue`. To do that we need to detect when player - * buys/sells/drops/picks up weapons, which we accomplish by catching - * `CheckReplacement()` event for weapon instances. This approach has two - * issues. - * One is that, if vanilla's code sets an incorrect sell value, - - * it's doing it after weapon is spawned and, therefore, - * after `CheckReplacement()` call, so we have, instead, to remember to do - * it later, as early as possible - * (either the next tick or before another operation with weapons). - * Another issue is that when you have a pistol and pick up a pistol of - * the same type, - at the moment dualies instance is spawned, - * the original pistol in player's inventory is gone and we can't use - * it's sell value to calculate new value of dual pistols. - * This problem is solved by separately recording the value for every - * single pistol every tick. - * However, if pistol pickups are placed close enough together on the map, - * player can start touching them (which triggers a pickup) at the same time, - * picking them both in a single tick. This leaves us no room to record - * the value of a single pistol players picks up first. - * To get it we use game rules to catch `OverridePickupQuery` event that's - * called before the first one gets destroyed, - * but after it's sell value was already set. - * Last issue is that when player picks up a second pistol - we don't know - * it's sell value and, therefore, can't calculate value of dual pistols. - * This is resolved by recording that value directly from a pickup, - * in abovementioned function `OverridePickupQuery`. - * NOTE: 9mm is an exception due to the fact that you always have at least - * one and the last one can't be sold. We'll deal with it by setting - * the following rule: sell value of the un-droppable pistol is always 0 - * and the value of a pair of 9mms is the value of the single droppable pistol. - */ - -// Some issues involve possible decrease in pistols' price and -// don't lead to exploit, but are still bugs and require fixing. -// If you have a Deagle in your inventory and then get another one -// (by either buying or picking it off the ground) - the price of resulting -// dual pistols will be set to the price of the last deagle, -// like the first one wasn't worth anything at all. -// In particular this means that (prices are off-perk for more clarity): -// 1. If you buy dual deagles (-1000 do$h) and then sell them at 75% of -// the cost (+750 do$h), you lose 250 do$h; -// 2. If you first buy a deagle (-500 do$h), then buy -// the second one (-500 do$h) and then sell them, you'll only get -// 75% of the cost of 1 deagle (+375 do$h), now losing 625 do$h; -// 3. So if you already have bought a deagle (-500 do$h), -// you can get a more expensive weapon by doing a stupid thing -// and first selling your Deagle (+375 do$h), -// then buying dual deagles (-1000 do$h). -// If you sell them after that, you'll gain 75% of the cost of -// dual deagles (+750 do$h), leaving you with losing only 375 do$h. -// Of course, situations described above are only relevant if you're planning -// to sell your weapons at some point and most people won't even notice it. -// But such an oversight still shouldn't exist in a game and we fix it by -// setting sell value of dualies as a sum of values of each pistol. -// Yet, fixing this issue leads to players having more expensive -// (while fairly priced) weapons than on vanilla, technically making -// the game easier. And some people might object to having that in -// a whitelisted bug-fixing feature. -// These people are, without a question, complete degenerates. -// But making mods for only non-mentally challenged isn't inclusive. -// So we add this option. -// Set it to `false` if you only want to fix ammo printing -// and leave the rest of the bullshit as-is. -var private config const bool allowSellValueIncrease; - -// Describe all the possible pairs of dual pistols in a vanilla game. -struct DualiesPair -{ - var class single; - var class dual; -}; -var private const array dualiesClasses; - -// Describe sell values that need to be applied at earliest later point. -struct WeaponValuePair -{ - // Reference to `KFWeapon` instance - var NativeActorRef weapon; - var float value; -}; -var private const array pendingValues; - -// Describe sell values of all currently existing single pistols. -struct WeaponDataRecord -{ - // Reference to `KFWeapon` instance - var NativeActorRef reference; - var class class; - var float value; - // The whole point of this structure is to remember value of a weapon - // after it's destroyed. Since `reference` will become `none` by then, - // we will use the `owner` reference to identify the weapon. - // Reference to `Pawn`. - var NativeActorRef owner; -}; -var private const array storedValues; - -// Sell value of the last seen pickup in `OverridePickupQuery` -var private int nextSellValue; - -protected function OnEnabled() -{ - local LevelInfo level; - local KFWeapon nextWeapon; - _.unreal.OnTick(self).connect = Tick; - _.unreal.gameRules.OnOverridePickupQuery(self).connect = PickupQuery; - level = _.unreal.GetLevel(); - // Find all weapons, that spawned when this fix wasn't running. - foreach level.DynamicActors(class'KFMod.KFWeapon', nextWeapon) { - RegisterSinglePistol(nextWeapon, false); - } -} - -protected function OnDisabled() -{ - local int i; - _.unreal.OnTick(self).Disconnect(); - _.unreal.gameRules.OnOverridePickupQuery(self).Disconnect(); - for (i = 0; i < storedValues.length; i += 1) - { - storedValues[i].reference.FreeSelf(); - storedValues[i].owner.FreeSelf(); - } - for (i = 0; i < pendingValues.length; i += 1) { - pendingValues[i].weapon.FreeSelf(); - } -} - -function bool PickupQuery( - Pawn other, - Pickup item, - out byte allowPickup) -{ - local KFWeaponPickup weaponPickup; - weaponPickup = KFWeaponPickup(item); - if (weaponPickup != none) - { - ApplyPendingValues(); - StoreSinglePistolValues(); - nextSellValue = weaponPickup.sellValue; - } - return false; -} - -// Finds a weapon of a given class in given `Pawn`'s inventory. -// Returns `none` if weapon isn't there. -private final function KFWeapon GetWeaponOfClass( - Pawn playerPawn, - class weaponClass -) -{ - local Inventory invIter; - if (playerPawn == none) return none; - - invIter = playerPawn.inventory; - while (invIter != none) - { - if (invIter.class == weaponClass) { - return KFWeapon(invIter); - } - invIter = invIter.inventory; - } - return none; -} - -// Gets weapon index in our record of dual pistol classes. -// Second variable determines whether we're searching for single -// or dual variant: -// ~ `true` - searching for single -// ~ `false` - for dual -// Returns `-1` if weapon isn't found -// (dual MK23 won't be found as a single weapon). -private final function int GetIndexAs(KFWeapon weapon, bool asSingle) -{ - local int i; - if (weapon == none) return -1; - - for (i = 0; i < dualiesClasses.length; i += 1) - { - if (asSingle && dualiesClasses[i].single == weapon.class) { - return i; - } - if (!asSingle && dualiesClasses[i].dual == weapon.class) { - return i; - } - } - return -1; -} +var public config bool allowSellValueIncrease; -// Calculates full cost of a weapon with a discount, -// dependent on it's instigator's perk. -private final function float GetFullCost(KFWeapon weapon) +protected function AssociativeArray ToData() { - local float cost; - local class pickupClass; - local KFPlayerReplicationInfo instigatorRI; - if (weapon == none) return 0.0; - pickupClass = class(weapon.default.pickupClass); - if (pickupClass == none) return 0.0; - - cost = pickupClass.default.cost; - if (weapon.instigator != none) - { - instigatorRI = - KFPlayerReplicationInfo(weapon.instigator.playerReplicationInfo); - } - if (instigatorRI != none && instigatorRI.clientVeteranSkill != none) - { - cost *= instigatorRI.clientVeteranSkill.static - .GetCostScaling(instigatorRI, pickupClass); - } - return cost; -} - -// If passed weapon is a pistol - we start tracking it's value; -// Otherwise - do nothing. -public final function RegisterSinglePistol( - KFWeapon singlePistol, - bool justSpawned -) -{ - local WeaponDataRecord newRecord; - if (singlePistol == none) return; - if (GetIndexAs(singlePistol, true) < 0) return; - - newRecord.reference = _.unreal.ActorRef(singlePistol); - newRecord.class = singlePistol.class; - newRecord.owner = _.unreal.ActorRef(singlePistol.instigator); - if (justSpawned) { - newRecord.value = nextSellValue; - } - else { - newRecord.value = singlePistol.sellValue; - } - storedValues[storedValues.length] = newRecord; -} - -// Fixes sell value after player throws one pistol out of a pair. -public final function FixCostAfterThrow(KFWeapon singlePistol) -{ - local int index; - local KFWeapon dualPistols; - if (singlePistol == none) return; - index = GetIndexAs(singlePistol, true); - if (index < 0) return; - dualPistols = GetWeaponOfClass( singlePistol.instigator, - dualiesClasses[index].dual); - if (dualPistols == none) return; - - // Sell value recorded into `dualPistols` will end up as a value of - // a dropped pickup. - // Sell value of `singlePistol` will be the value for the pistol, - // left in player's hands. - if (dualPistols.class == class'KFMod.Single') - { - // 9mm is an exception. - // Remaining weapon costs nothing. - singlePistol.sellValue = 0; - // We don't change the sell value of the dropped weapon, - // as it's default behavior to transfer full value of a pair to it. - return; - } - // For other pistols - divide the value. - singlePistol.sellValue = dualPistols.sellValue / 2; - dualPistols.sellValue = singlePistol.sellValue; -} - -// Fixes sell value after buying a pair of dual pistols, -// if player already had a single version. -public final function FixCostAfterBuying(KFWeapon dualPistols) -{ - local int index; - local KFWeapon singlePistol; - local WeaponValuePair newPendingValue; - if (dualPistols == none) return; - index = GetIndexAs(dualPistols, false); - if (index < 0) return; - singlePistol = GetWeaponOfClass(dualPistols.instigator, - dualiesClasses[index].single); - if (singlePistol == none) return; - - // `singlePistol` will get destroyed, so it's sell value is irrelevant. - // `dualPistols` will be the new pair of pistols, but it's value will - // get overwritten by vanilla's code after this function. - // So we must add it to pending values to be changed later. - newPendingValue.weapon = _.unreal.ActorRef(dualPistols); - if (dualPistols.class == class'KFMod.Dualies') - { - // 9mm is an exception. - // The value of pair of 9mms is the price of additional pistol, - // that defined as a price of a pair in game. - newPendingValue.value = GetFullCost(dualPistols) * 0.75; - } - else - { - // Otherwise price of a pair is the price of two pistols: - // `singlePistol.sellValue` - the one we had - // `(FullCost / 2) * 0.75` - and the one we bought - newPendingValue.value = singlePistol.sellValue - + (GetFullCost(dualPistols) / 2) * 0.75; - } - pendingValues[pendingValues.length] = newPendingValue; -} - -// Fixes sell value after player picks up a single pistol, -// while already having one of the same time in his inventory. -public final function FixCostAfterPickUp(KFWeapon dualPistols) -{ - local int i; - local int index; - local KFWeapon singlePistol; - local WeaponValuePair newPendingValue; - if (dualPistols == none) return; - // In both cases of: - // 1. buying dualies, without having a single pistol of - // corresponding type; - // 2. picking up a second pistol, while having another one; - // by the time of `CheckReplacement()` (and, therefore, this function) - // is called, there's no longer any single pistol in player's inventory - // (in first case it never was there, in second - it got destroyed). - // To distinguish between those possibilities we can check the owner of - // the spawned weapon, since it's only set to instigator at the time of - // `CheckReplacement()` when player picks up a weapon. - // So we require that owner exists. - if (dualPistols.owner == none) return; - index = GetIndexAs(dualPistols, false); - if (index < 0) return; - singlePistol = GetWeaponOfClass(dualPistols.instigator, - dualiesClasses[index].single); - if (singlePistol != none) return; - - if (nextSellValue == -1) { - nextSellValue = GetFullCost(dualPistols) * 0.75; - } - for (i = 0; i < storedValues.length; i += 1) - { - if (storedValues[i].reference.Get() != none) continue; - if (storedValues[i].class != dualiesClasses[index].single) continue; - if (storedValues[i].owner.Get() != dualPistols.instigator) continue; - - newPendingValue.weapon = _.unreal.ActorRef(dualPistols); - newPendingValue.value = storedValues[i].value + nextSellValue; - pendingValues[pendingValues.length] = newPendingValue; - break; - } -} - -private final function ApplyPendingValues() -{ - local int i; - local KFWeapon nextWeapon; - for (i = 0; i < pendingValues.length; i += 1) - { - nextWeapon = KFWeapon(pendingValues[i].weapon.Get()); - pendingValues[i].weapon.FreeSelf(); - if (nextWeapon == none) { - continue; - } - // Our fixes can only increase the correct (`!= -1`) - // sell value of weapons, so if we only need to change sell value - // if we're allowed to increase it or it's incorrect. - if (allowSellValueIncrease || nextWeapon.sellValue == -1) { - nextWeapon.sellValue = pendingValues[i].value; - } - } - pendingValues.length = 0; + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(P("allowSellValueIncrease"), allowSellValueIncrease, true); + return data; } -private final function StoreSinglePistolValues() +protected function FromData(AssociativeArray source) { - local int i; - local KFWeapon nextWeapon; - while (i < storedValues.length) + if (source != none) { - nextWeapon = KFWeapon(storedValues[i].reference.Get()); - if (nextWeapon == none) - { - storedValues[i].reference.FreeSelf(); - storedValues[i].owner.FreeSelf(); - storedValues.Remove(i, 1); - continue; - } - storedValues[i].owner.Set(nextWeapon.instigator); - storedValues[i].value = nextWeapon.sellValue; - i += 1; + allowSellValueIncrease = + source.GetBool(P("allowSellValueIncrease"), true); } } -private function Tick(float delta, float timeDilationCoefficient) +protected function DefaultIt() { - ApplyPendingValues(); - StoreSinglePistolValues(); + allowSellValueIncrease = true; } defaultproperties { + configName = "AcediaFixes" allowSellValueIncrease = true - // Inner variables - dualiesClasses(0)=(single=class'KFMod.Single',dual=class'KFMod.Dualies') - dualiesClasses(1)=(single=class'KFMod.Magnum44Pistol',dual=class'KFMod.Dual44Magnum') - dualiesClasses(2)=(single=class'KFMod.MK23Pistol',dual=class'KFMod.DualMK23Pistol') - dualiesClasses(3)=(single=class'KFMod.Deagle',dual=class'KFMod.DualDeagle') - dualiesClasses(4)=(single=class'KFMod.GoldenDeagle',dual=class'KFMod.GoldenDualDeagle') - dualiesClasses(5)=(single=class'KFMod.FlareRevolver',dual=class'KFMod.DualFlareRevolver') - // Listeners - requiredListeners(0) = class'MutatorListener_FixDualiesCost' } \ No newline at end of file diff --git a/sources/FixDualiesCost/FixDualiesCost_Feature.uc b/sources/FixDualiesCost/FixDualiesCost_Feature.uc new file mode 100644 index 0000000..8dc0cb1 --- /dev/null +++ b/sources/FixDualiesCost/FixDualiesCost_Feature.uc @@ -0,0 +1,485 @@ +/** + * This feature fixes several issues related to the selling price of both + * single and dual pistols, all originating from the existence of dual weapons. + * Most notable issue is the ability to "print" money by buying and + * selling pistols in a certain way. + * + * It fixes all of the issues by manually setting pistols' + * `SellValue` variables to proper values. + * Fix only works with vanilla pistols, as it's unpredictable what + * custom ones can do and they can handle these issues on their own + * in a better way. + * Copyright 2020 - 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixDualiesCost_Feature extends Feature; + +/** + * Issues with pistols' cost may look varied and surface in + * a plethora of ways, but all of them originate from the two main errors + * in vanilla's code: + * 1. If you have a pistol in your inventory at the time when you + * buy/pickup another one - the sell value of resulting dualies is + * incorrectly set to the sell value of the second pistol; + * 2. When player has dual pistols and drops one on the floor, - + * the sell value for the one left with the player isn't set. + * All weapons in Killing Floor get sell value assigned to them + * (appropriately, in a `SellValue` variable). This is to ensure that the sell + * price is set the moment players buys the gun. Otherwise, due to ridiculous + * perked discounts, you'd be able to buy a pistol at 30% price + * as sharpshooter, but sell at 75% of a price as any other perk, + * resulting in 45% of pure profit. + * Unfortunately, that's exactly what happens when `SellValue` isn't set + * (left as it's default value of `-1`): sell value of such weapons is + * determined only at the moment of sale and depends on the perk of the seller, + * allowing for possible exploits. + * + * These issues are fixed by directly assigning + * proper values to `SellValue`. To do that we need to detect when player + * buys/sells/drops/picks up weapons, which we accomplish by catching + * `CheckReplacement()` event for weapon instances. This approach has two + * issues. + * One is that, if vanilla's code sets an incorrect sell value, - + * it's doing it after weapon is spawned and, therefore, + * after `CheckReplacement()` call, so we have, instead, to remember to do + * it later, as early as possible + * (either the next tick or before another operation with weapons). + * Another issue is that when you have a pistol and pick up a pistol of + * the same type, - at the moment dualies instance is spawned, + * the original pistol in player's inventory is gone and we can't use + * it's sell value to calculate new value of dual pistols. + * This problem is solved by separately recording the value for every + * single pistol every tick. + * However, if pistol pickups are placed close enough together on the map, + * player can start touching them (which triggers a pickup) at the same time, + * picking them both in a single tick. This leaves us no room to record + * the value of a single pistol players picks up first. + * To get it we use game rules to catch `OverridePickupQuery` event that's + * called before the first one gets destroyed, + * but after it's sell value was already set. + * Last issue is that when player picks up a second pistol - we don't know + * it's sell value and, therefore, can't calculate value of dual pistols. + * This is resolved by recording that value directly from a pickup, + * in abovementioned function `OverridePickupQuery`. + * NOTE: 9mm is an exception due to the fact that you always have at least + * one and the last one can't be sold. We'll deal with it by setting + * the following rule: sell value of the un-droppable pistol is always 0 + * and the value of a pair of 9mms is the value of the single droppable pistol. + */ + +// Some issues involve possible decrease in pistols' price and +// don't lead to exploit, but are still bugs and require fixing. +// If you have a Deagle in your inventory and then get another one +// (by either buying or picking it off the ground) - the price of resulting +// dual pistols will be set to the price of the last deagle, +// like the first one wasn't worth anything at all. +// In particular this means that (prices are off-perk for more clarity): +// 1. If you buy dual deagles (-1000 do$h) and then sell them at 75% of +// the cost (+750 do$h), you lose 250 do$h; +// 2. If you first buy a deagle (-500 do$h), then buy +// the second one (-500 do$h) and then sell them, you'll only get +// 75% of the cost of 1 deagle (+375 do$h), now losing 625 do$h; +// 3. So if you already have bought a deagle (-500 do$h), +// you can get a more expensive weapon by doing a stupid thing +// and first selling your Deagle (+375 do$h), +// then buying dual deagles (-1000 do$h). +// If you sell them after that, you'll gain 75% of the cost of +// dual deagles (+750 do$h), leaving you with losing only 375 do$h. +// Of course, situations described above are only relevant if you're planning +// to sell your weapons at some point and most people won't even notice it. +// But such an oversight still shouldn't exist in a game and we fix it by +// setting sell value of dualies as a sum of values of each pistol. +// Yet, fixing this issue leads to players having more expensive +// (while fairly priced) weapons than on vanilla, technically making +// the game easier. And some people might object to having that in +// a whitelisted bug-fixing feature. +// These people are, without a question, complete degenerates. +// But making mods for only non-mentally challenged isn't inclusive. +// So we add this option. +// Set it to `false` if you only want to fix ammo printing +// and leave the rest of the bullshit as-is. +var private /*config*/ bool allowSellValueIncrease; + +// Describe all the possible pairs of dual pistols in a vanilla game. +struct DualiesPair +{ + var class single; + var class dual; +}; +var private const array dualiesClasses; + +// Describe sell values that need to be applied at earliest later point. +struct WeaponValuePair +{ + // Reference to `KFWeapon` instance + var NativeActorRef weapon; + var float value; +}; +var private const array pendingValues; + +// Describe sell values of all currently existing single pistols. +struct WeaponDataRecord +{ + // Reference to `KFWeapon` instance + var NativeActorRef reference; + var class class; + var float value; + // The whole point of this structure is to remember value of a weapon + // after it's destroyed. Since `reference` will become `none` by then, + // we will use the `owner` reference to identify the weapon. + // Reference to `Pawn`. + var NativeActorRef owner; +}; +var private const array storedValues; + +// Sell value of the last seen pickup in `OverridePickupQuery` +var private int nextSellValue; + +protected function OnEnabled() +{ + local LevelInfo level; + local KFWeapon nextWeapon; + _.unreal.OnTick(self).connect = Tick; + _.unreal.mutator.OnCheckReplacement(self).connect = CheckReplacement; + _.unreal.gameRules.OnOverridePickupQuery(self).connect = PickupQuery; + level = _.unreal.GetLevel(); + // Find all weapons, that spawned when this fix wasn't running. + foreach level.DynamicActors(class'KFMod.KFWeapon', nextWeapon) { + RegisterSinglePistol(nextWeapon, false); + } +} + +protected function OnDisabled() +{ + local int i; + _.unreal.OnTick(self).Disconnect(); + _.unreal.mutator.OnCheckReplacement(self).Disconnect(); + _.unreal.gameRules.OnOverridePickupQuery(self).Disconnect(); + for (i = 0; i < storedValues.length; i += 1) + { + storedValues[i].reference.FreeSelf(); + storedValues[i].owner.FreeSelf(); + } + for (i = 0; i < pendingValues.length; i += 1) { + pendingValues[i].weapon.FreeSelf(); + } +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixDualiesCost newConfig; + newConfig = FixDualiesCost(config); + if (newConfig == none) { + return; + } + allowSellValueIncrease = newConfig.allowSellValueIncrease; +} + +private final function bool CheckReplacement( + Actor other, + out byte isSuperRelevant) +{ + local KFWeapon weapon; + weapon = KFWeapon(other); + if (weapon == none) { + return true; + } + RegisterSinglePistol(weapon, true); + FixCostAfterThrow(weapon); + FixCostAfterBuying(weapon); + FixCostAfterPickUp(weapon); + return true; +} + +function bool PickupQuery( + Pawn other, + Pickup item, + out byte allowPickup) +{ + local KFWeaponPickup weaponPickup; + weaponPickup = KFWeaponPickup(item); + if (weaponPickup != none) + { + ApplyPendingValues(); + StoreSinglePistolValues(); + nextSellValue = weaponPickup.sellValue; + } + return false; +} + +// Finds a weapon of a given class in given `Pawn`'s inventory. +// Returns `none` if weapon isn't there. +private final function KFWeapon GetWeaponOfClass( + Pawn playerPawn, + class weaponClass +) +{ + local Inventory invIter; + if (playerPawn == none) return none; + + invIter = playerPawn.inventory; + while (invIter != none) + { + if (invIter.class == weaponClass) { + return KFWeapon(invIter); + } + invIter = invIter.inventory; + } + return none; +} + +// Gets weapon index in our record of dual pistol classes. +// Second variable determines whether we're searching for single +// or dual variant: +// ~ `true` - searching for single +// ~ `false` - for dual +// Returns `-1` if weapon isn't found +// (dual MK23 won't be found as a single weapon). +private final function int GetIndexAs(KFWeapon weapon, bool asSingle) +{ + local int i; + if (weapon == none) return -1; + + for (i = 0; i < dualiesClasses.length; i += 1) + { + if (asSingle && dualiesClasses[i].single == weapon.class) { + return i; + } + if (!asSingle && dualiesClasses[i].dual == weapon.class) { + return i; + } + } + return -1; +} + +// Calculates full cost of a weapon with a discount, +// dependent on it's instigator's perk. +private final function float GetFullCost(KFWeapon weapon) +{ + local float cost; + local class pickupClass; + local KFPlayerReplicationInfo instigatorRI; + if (weapon == none) return 0.0; + pickupClass = class(weapon.default.pickupClass); + if (pickupClass == none) return 0.0; + + cost = pickupClass.default.cost; + if (weapon.instigator != none) + { + instigatorRI = + KFPlayerReplicationInfo(weapon.instigator.playerReplicationInfo); + } + if (instigatorRI != none && instigatorRI.clientVeteranSkill != none) + { + cost *= instigatorRI.clientVeteranSkill.static + .GetCostScaling(instigatorRI, pickupClass); + } + return cost; +} + +// If passed weapon is a pistol - we start tracking it's value; +// Otherwise - do nothing. +public final function RegisterSinglePistol( + KFWeapon singlePistol, + bool justSpawned +) +{ + local WeaponDataRecord newRecord; + if (singlePistol == none) return; + if (GetIndexAs(singlePistol, true) < 0) return; + + newRecord.reference = _.unreal.ActorRef(singlePistol); + newRecord.class = singlePistol.class; + newRecord.owner = _.unreal.ActorRef(singlePistol.instigator); + if (justSpawned) { + newRecord.value = nextSellValue; + } + else { + newRecord.value = singlePistol.sellValue; + } + storedValues[storedValues.length] = newRecord; +} + +// Fixes sell value after player throws one pistol out of a pair. +public final function FixCostAfterThrow(KFWeapon singlePistol) +{ + local int index; + local KFWeapon dualPistols; + if (singlePistol == none) return; + index = GetIndexAs(singlePistol, true); + if (index < 0) return; + dualPistols = GetWeaponOfClass( singlePistol.instigator, + dualiesClasses[index].dual); + if (dualPistols == none) return; + + // Sell value recorded into `dualPistols` will end up as a value of + // a dropped pickup. + // Sell value of `singlePistol` will be the value for the pistol, + // left in player's hands. + if (dualPistols.class == class'KFMod.Single') + { + // 9mm is an exception. + // Remaining weapon costs nothing. + singlePistol.sellValue = 0; + // We don't change the sell value of the dropped weapon, + // as it's default behavior to transfer full value of a pair to it. + return; + } + // For other pistols - divide the value. + singlePistol.sellValue = dualPistols.sellValue / 2; + dualPistols.sellValue = singlePistol.sellValue; +} + +// Fixes sell value after buying a pair of dual pistols, +// if player already had a single version. +public final function FixCostAfterBuying(KFWeapon dualPistols) +{ + local int index; + local KFWeapon singlePistol; + local WeaponValuePair newPendingValue; + if (dualPistols == none) return; + index = GetIndexAs(dualPistols, false); + if (index < 0) return; + singlePistol = GetWeaponOfClass(dualPistols.instigator, + dualiesClasses[index].single); + if (singlePistol == none) return; + + // `singlePistol` will get destroyed, so it's sell value is irrelevant. + // `dualPistols` will be the new pair of pistols, but it's value will + // get overwritten by vanilla's code after this function. + // So we must add it to pending values to be changed later. + newPendingValue.weapon = _.unreal.ActorRef(dualPistols); + if (dualPistols.class == class'KFMod.Dualies') + { + // 9mm is an exception. + // The value of pair of 9mms is the price of additional pistol, + // that defined as a price of a pair in game. + newPendingValue.value = GetFullCost(dualPistols) * 0.75; + } + else + { + // Otherwise price of a pair is the price of two pistols: + // `singlePistol.sellValue` - the one we had + // `(FullCost / 2) * 0.75` - and the one we bought + newPendingValue.value = singlePistol.sellValue + + (GetFullCost(dualPistols) / 2) * 0.75; + } + pendingValues[pendingValues.length] = newPendingValue; +} + +// Fixes sell value after player picks up a single pistol, +// while already having one of the same time in his inventory. +public final function FixCostAfterPickUp(KFWeapon dualPistols) +{ + local int i; + local int index; + local KFWeapon singlePistol; + local WeaponValuePair newPendingValue; + if (dualPistols == none) return; + // In both cases of: + // 1. buying dualies, without having a single pistol of + // corresponding type; + // 2. picking up a second pistol, while having another one; + // by the time of `CheckReplacement()` (and, therefore, this function) + // is called, there's no longer any single pistol in player's inventory + // (in first case it never was there, in second - it got destroyed). + // To distinguish between those possibilities we can check the owner of + // the spawned weapon, since it's only set to instigator at the time of + // `CheckReplacement()` when player picks up a weapon. + // So we require that owner exists. + if (dualPistols.owner == none) return; + index = GetIndexAs(dualPistols, false); + if (index < 0) return; + singlePistol = GetWeaponOfClass(dualPistols.instigator, + dualiesClasses[index].single); + if (singlePistol != none) return; + + if (nextSellValue == -1) { + nextSellValue = GetFullCost(dualPistols) * 0.75; + } + for (i = 0; i < storedValues.length; i += 1) + { + if (storedValues[i].reference.Get() != none) continue; + if (storedValues[i].class != dualiesClasses[index].single) continue; + if (storedValues[i].owner.Get() != dualPistols.instigator) continue; + + newPendingValue.weapon = _.unreal.ActorRef(dualPistols); + newPendingValue.value = storedValues[i].value + nextSellValue; + pendingValues[pendingValues.length] = newPendingValue; + break; + } +} + +private final function ApplyPendingValues() +{ + local int i; + local KFWeapon nextWeapon; + for (i = 0; i < pendingValues.length; i += 1) + { + nextWeapon = KFWeapon(pendingValues[i].weapon.Get()); + pendingValues[i].weapon.FreeSelf(); + if (nextWeapon == none) { + continue; + } + // Our fixes can only increase the correct (`!= -1`) + // sell value of weapons, so if we only need to change sell value + // if we're allowed to increase it or it's incorrect. + if (allowSellValueIncrease || nextWeapon.sellValue == -1) { + nextWeapon.sellValue = pendingValues[i].value; + } + } + pendingValues.length = 0; +} + +private final function StoreSinglePistolValues() +{ + local int i; + local KFWeapon nextWeapon; + while (i < storedValues.length) + { + nextWeapon = KFWeapon(storedValues[i].reference.Get()); + if (nextWeapon == none) + { + storedValues[i].reference.FreeSelf(); + storedValues[i].owner.FreeSelf(); + storedValues.Remove(i, 1); + continue; + } + storedValues[i].owner.Set(nextWeapon.instigator); + storedValues[i].value = nextWeapon.sellValue; + i += 1; + } +} + +private function Tick(float delta, float timeDilationCoefficient) +{ + ApplyPendingValues(); + StoreSinglePistolValues(); +} + +defaultproperties +{ + configClass = class'FixDualiesCost' + allowSellValueIncrease = true + // Inner variables + dualiesClasses(0)=(single=class'KFMod.Single',dual=class'KFMod.Dualies') + dualiesClasses(1)=(single=class'KFMod.Magnum44Pistol',dual=class'KFMod.Dual44Magnum') + dualiesClasses(2)=(single=class'KFMod.MK23Pistol',dual=class'KFMod.DualMK23Pistol') + dualiesClasses(3)=(single=class'KFMod.Deagle',dual=class'KFMod.DualDeagle') + dualiesClasses(4)=(single=class'KFMod.GoldenDeagle',dual=class'KFMod.GoldenDualDeagle') + dualiesClasses(5)=(single=class'KFMod.FlareRevolver',dual=class'KFMod.DualFlareRevolver') +} \ No newline at end of file diff --git a/sources/FixDualiesCost/MutatorListener_FixDualiesCost.uc b/sources/FixDualiesCost/MutatorListener_FixDualiesCost.uc deleted file mode 100644 index 0f209ae..0000000 --- a/sources/FixDualiesCost/MutatorListener_FixDualiesCost.uc +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Overloaded mutator events listener to catch when pistol-type weapons - * (single or dual) are spawned and to correct their price. - * Copyright 2020 Anton Tarasenko - *------------------------------------------------------------------------------ - * This file is part of Acedia. - * - * Acedia is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Acedia is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Acedia. If not, see . - */ -class MutatorListener_FixDualiesCost extends MutatorListenerBase - abstract; - -static function bool CheckReplacement(Actor other, out byte isSuperRelevant) -{ - local KFWeapon weapon; - local FixDualiesCost dualiesCostFix; - weapon = KFWeapon(other); - if (weapon == none) return true; - dualiesCostFix = FixDualiesCost(class'FixDualiesCost'.static.GetInstance()); - if (dualiesCostFix == none) return true; - - dualiesCostFix.RegisterSinglePistol(weapon, true); - dualiesCostFix.FixCostAfterThrow(weapon); - dualiesCostFix.FixCostAfterBuying(weapon); - dualiesCostFix.FixCostAfterPickUp(weapon); - return true; -} - -defaultproperties -{ - relatedEvents = class'MutatorEvents' -} \ No newline at end of file diff --git a/sources/FixFFHack/FixFFHack.uc b/sources/FixFFHack/FixFFHack.uc index 37d3f2c..97964b2 100644 --- a/sources/FixFFHack/FixFFHack.uc +++ b/sources/FixFFHack/FixFFHack.uc @@ -1,12 +1,6 @@ /** - * This feature fixes a bug that can allow players to bypass server's - * friendly fire limitations and teamkill. - * Usual fixes apply friendly fire scale to suspicious damage themselves, which - * also disables some of the environmental damage. - * In order to avoid that, this fix allows server owner to define precisely - * to what damage types to apply the friendly fire scaling. - * It should be all damage types related to projectiles. - * Copyright 2019 - 2021 Anton Tarasenko + * Config object for `FixFFHack_Feature`. + * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -23,117 +17,108 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixFFHack extends Feature +class FixFFHack extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * It's possible to bypass friendly fire damage scaling and always deal - * full damage to other players, if one were to either leave the server or - * spectate right after shooting a projectile. We use game rules to catch - * such occurrences and apply friendly fire scaling to weapons, - * specified by server admins. - * To specify required subset of weapons, one must first - * chose a general rule (scale by default / don't scale by default) and then, - * optionally, add exceptions to it. - * Choosing `scaleByDefault == true` as a general rule will make this fix - * behave in the similar way to `KFExplosiveFix` by mutant and will disable - * some environmental sources of damage on some maps. One can then add relevant - * damage classes as exceptions to fix that downside, but making an extensive - * list of such sources might prove problematic. - * On the other hand, setting `scaleByDefault == false` will allow to get - * rid of team-killing exploits by simply adding damage types of all - * projectile weapons, used on a server. This fix comes with such filled-in - * list of all vanilla projectile classes. - */ - -// Defines a general rule for choosing whether or not to apply -// friendly fire scaling. -// This can be overwritten by exceptions (`alwaysScale` or `neverScale`). -// Enabling scaling by default without any exceptions in `neverScale` will -// make this fix behave almost identically to Mutant's -// 'Explosives Fix Mutator'. -var private config const bool scaleByDefault; -// Damage types, for which we should always reapply friendly fire scaling. -var private config const array< class > alwaysScale; -// Damage types, for which we should never reapply friendly fire scaling. -var private config const array< class > neverScale; - -protected function OnEnabled() -{ - _.unreal.gameRules.OnNetDamage(self).connect = NetDamage; -} +var public config bool scaleByDefault; +var public config array< class > alwaysScale; +var public config array< class > neverScale; -protected function OnDisabled() +protected function AssociativeArray ToData() { - _.unreal.gameRules.OnNetDamage(self).Disconnect(); + local int i; + local DynamicArray damageTypeArray; + local AssociativeArray data; + data = _.collections.EmptyAssociativeArray(); + data.SetBool(P("scaleByDefault"), scaleByDefault, true); + damageTypeArray = _.collections.EmptyDynamicArray(); + for (i = 0; i < alwaysScale.length; i += 1) { + damageTypeArray.AddItem(_.text.FromString(string(alwaysScale[i]))); + } + data.SetItem(P("alwaysScale"), damageTypeArray); + damageTypeArray = _.collections.EmptyDynamicArray(); + for (i = 0; i < neverScale.length; i += 1) { + damageTypeArray.AddItem(_.text.FromString(string(neverScale[i]))); + } + data.SetItem(P("neverScale"), damageTypeArray); + return data; } -function int NetDamage( - int originalDamage, - int damage, - Pawn injured, - Pawn instigator, - Vector hitLocation, - out Vector momentum, - class damageType) +protected function FromData(AssociativeArray source) { - // Something is very wrong and we can just bail on this damage - if (damageType == none) { - return 0; + local int i; + local DynamicArray damageTypeArray; + if (source == none) { + return; } - // We only check when suspicious instigators that aren't a world - if (!damageType.default.bCausedByWorld && IsSuspicious(instigator)) - { - if (ShouldScaleDamage(damageType)) + scaleByDefault = source.GetBool(P("scaleByDefault")); + alwaysScale.length = 0; + damageTypeArray = source.GetDynamicArray(P("alwaysScale")); + if (damageTypeArray != none) { + for (i = 0; i < damageTypeArray.GetLength(); i += 1) { - // Remove pushback to avoid environmental kills - momentum = Vect(0.0, 0.0, 0.0); - damage *= _.unreal.GetKFGameType().friendlyFireScale; + alwaysScale[i] = class( + _.memory.LoadClass(damageTypeArray.GetText(i))); } } - return damage; -} - -private function bool IsSuspicious(Pawn instigator) -{ - // Instigator vanished - if (instigator == none) return true; - - // Instigator already became spectator - if (KFPawn(instigator) != none) - { - if (instigator.playerReplicationInfo != none) { - return instigator.playerReplicationInfo.bOnlySpectator; + neverScale.length = 0; + damageTypeArray = source.GetDynamicArray(P("neverScale")); + if (damageTypeArray != none) { + for (i = 0; i < damageTypeArray.GetLength(); i += 1) + { + neverScale[i] = class( + _.memory.LoadClass(damageTypeArray.GetText(i))); } - return true; // Replication info is gone => suspicious } - return false; } -// Checks general rule and exception list -public final function bool ShouldScaleDamage(class damageType) +protected function DefaultIt() { - local int i; - local array< class > exceptions; - if (damageType == none) return false; - - if (scaleByDefault) { - exceptions = neverScale; - } - else { - exceptions = alwaysScale; - } - for (i = 0; i < exceptions.length; i += 1) - { - if (exceptions[i] == damageType) { - return (!scaleByDefault); - } - } - return scaleByDefault; + scaleByDefault = false; + alwaysScale.length = 0; + neverScale.length = 0; + // Vanilla damage types for projectiles + alwaysScale[0] = class'KFMod.DamTypeCrossbuzzsawHeadShot'; + alwaysScale[1] = class'KFMod.DamTypeCrossbuzzsaw'; + alwaysScale[2] = class'KFMod.DamTypeFrag'; + alwaysScale[3] = class'KFMod.DamTypePipeBomb'; + alwaysScale[4] = class'KFMod.DamTypeM203Grenade'; + alwaysScale[5] = class'KFMod.DamTypeM79Grenade'; + alwaysScale[6] = class'KFMod.DamTypeM79GrenadeImpact'; + alwaysScale[7] = class'KFMod.DamTypeM32Grenade'; + alwaysScale[8] = class'KFMod.DamTypeLAW'; + alwaysScale[9] = class'KFMod.DamTypeLawRocketImpact'; + alwaysScale[10] = class'KFMod.DamTypeFlameNade'; + alwaysScale[11] = class'KFMod.DamTypeFlareRevolver'; + alwaysScale[12] = class'KFMod.DamTypeFlareProjectileImpact'; + alwaysScale[13] = class'KFMod.DamTypeBurned'; + alwaysScale[14] = class'KFMod.DamTypeTrenchgun'; + alwaysScale[15] = class'KFMod.DamTypeHuskGun'; + alwaysScale[16] = class'KFMod.DamTypeCrossbow'; + alwaysScale[17] = class'KFMod.DamTypeCrossbowHeadShot'; + alwaysScale[18] = class'KFMod.DamTypeM99SniperRifle'; + alwaysScale[19] = class'KFMod.DamTypeM99HeadShot'; + alwaysScale[20] = class'KFMod.DamTypeShotgun'; + alwaysScale[21] = class'KFMod.DamTypeNailGun'; + alwaysScale[22] = class'KFMod.DamTypeDBShotgun'; + alwaysScale[23] = class'KFMod.DamTypeKSGShotgun'; + alwaysScale[24] = class'KFMod.DamTypeBenelli'; + alwaysScale[25] = class'KFMod.DamTypeSPGrenade'; + alwaysScale[26] = class'KFMod.DamTypeSPGrenadeImpact'; + alwaysScale[27] = class'KFMod.DamTypeSeekerSixRocket'; + alwaysScale[28] = class'KFMod.DamTypeSeekerRocketImpact'; + alwaysScale[29] = class'KFMod.DamTypeSealSquealExplosion'; + alwaysScale[30] = class'KFMod.DamTypeRocketImpact'; + alwaysScale[31] = class'KFMod.DamTypeBlowerThrower'; + alwaysScale[32] = class'KFMod.DamTypeSPShotgun'; + alwaysScale[33] = class'KFMod.DamTypeZEDGun'; + alwaysScale[34] = class'KFMod.DamTypeZEDGunMKII'; } defaultproperties { + configName = "AcediaFixes" scaleByDefault = false // Vanilla damage types for projectiles alwaysScale(0) = class'KFMod.DamTypeCrossbuzzsawHeadShot' diff --git a/sources/FixFFHack/FixFFHack_Feature.uc b/sources/FixFFHack/FixFFHack_Feature.uc new file mode 100644 index 0000000..608acea --- /dev/null +++ b/sources/FixFFHack/FixFFHack_Feature.uc @@ -0,0 +1,186 @@ +/** + * This feature fixes a bug that can allow players to bypass server's + * friendly fire limitations and teamkill. + * Usual fixes apply friendly fire scale to suspicious damage themselves, which + * also disables some of the environmental damage. + * In order to avoid that, this fix allows server owner to define precisely + * to what damage types to apply the friendly fire scaling. + * It should be all damage types related to projectiles. + * Copyright 2019 - 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixFFHack_Feature extends Feature; + +/** + * It's possible to bypass friendly fire damage scaling and always deal + * full damage to other players, if one were to either leave the server or + * spectate right after shooting a projectile. We use game rules to catch + * such occurrences and apply friendly fire scaling to weapons, + * specified by server admins. + * To specify required subset of weapons, one must first + * chose a general rule (scale by default / don't scale by default) and then, + * optionally, add exceptions to it. + * Choosing `scaleByDefault == true` as a general rule will make this fix + * behave in the similar way to `KFExplosiveFix` by mutant and will disable + * some environmental sources of damage on some maps. One can then add relevant + * damage classes as exceptions to fix that downside, but making an extensive + * list of such sources might prove problematic. + * On the other hand, setting `scaleByDefault == false` will allow to get + * rid of team-killing exploits by simply adding damage types of all + * projectile weapons, used on a server. This fix comes with such filled-in + * list of all vanilla projectile classes. + */ + +// Defines a general rule for choosing whether or not to apply +// friendly fire scaling. +// This can be overwritten by exceptions (`alwaysScale` or `neverScale`). +// Enabling scaling by default without any exceptions in `neverScale` will +// make this fix behave almost identically to Mutant's +// 'Explosives Fix Mutator'. +var private /*config*/ bool scaleByDefault; +// Damage types, for which we should always reapply friendly fire scaling. +var private /*config*/ array< class > alwaysScale; +// Damage types, for which we should never reapply friendly fire scaling. +var private /*config*/ array< class > neverScale; + +protected function OnEnabled() +{ + _.unreal.gameRules.OnNetDamage(self).connect = NetDamage; +} + +protected function OnDisabled() +{ + _.unreal.gameRules.OnNetDamage(self).Disconnect(); +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixFFHack newConfig; + newConfig = FixFFHack(config); + if (newConfig == none) { + return; + } + scaleByDefault = newConfig.scaleByDefault; + alwaysScale = newConfig.alwaysScale; + neverScale = newConfig.neverScale; +} + +function int NetDamage( + int originalDamage, + int damage, + Pawn injured, + Pawn instigator, + Vector hitLocation, + out Vector momentum, + class damageType) +{ + // Something is very wrong and we can just bail on this damage + if (damageType == none) { + return 0; + } + // We only check when suspicious instigators that aren't a world + if (!damageType.default.bCausedByWorld && IsSuspicious(instigator)) + { + if (ShouldScaleDamage(damageType)) + { + // Remove pushback to avoid environmental kills + momentum = Vect(0.0, 0.0, 0.0); + damage *= _.unreal.GetKFGameType().friendlyFireScale; + } + } + return damage; +} + +private function bool IsSuspicious(Pawn instigator) +{ + // Instigator vanished + if (instigator == none) return true; + + // Instigator already became spectator + if (KFPawn(instigator) != none) + { + if (instigator.playerReplicationInfo != none) { + return instigator.playerReplicationInfo.bOnlySpectator; + } + return true; // Replication info is gone => suspicious + } + return false; +} + +// Checks general rule and exception list +public final function bool ShouldScaleDamage(class damageType) +{ + local int i; + local array< class > exceptions; + if (damageType == none) return false; + + if (scaleByDefault) { + exceptions = neverScale; + } + else { + exceptions = alwaysScale; + } + for (i = 0; i < exceptions.length; i += 1) + { + if (exceptions[i] == damageType) { + return (!scaleByDefault); + } + } + return scaleByDefault; +} + +defaultproperties +{ + configClass = class'FixFFHack' + scaleByDefault = false + // Vanilla damage types for projectiles + alwaysScale(0) = class'KFMod.DamTypeCrossbuzzsawHeadShot' + alwaysScale(1) = class'KFMod.DamTypeCrossbuzzsaw' + alwaysScale(2) = class'KFMod.DamTypeFrag' + alwaysScale(3) = class'KFMod.DamTypePipeBomb' + alwaysScale(4) = class'KFMod.DamTypeM203Grenade' + alwaysScale(5) = class'KFMod.DamTypeM79Grenade' + alwaysScale(6) = class'KFMod.DamTypeM79GrenadeImpact' + alwaysScale(7) = class'KFMod.DamTypeM32Grenade' + alwaysScale(8) = class'KFMod.DamTypeLAW' + alwaysScale(9) = class'KFMod.DamTypeLawRocketImpact' + alwaysScale(10) = class'KFMod.DamTypeFlameNade' + alwaysScale(11) = class'KFMod.DamTypeFlareRevolver' + alwaysScale(12) = class'KFMod.DamTypeFlareProjectileImpact' + alwaysScale(13) = class'KFMod.DamTypeBurned' + alwaysScale(14) = class'KFMod.DamTypeTrenchgun' + alwaysScale(15) = class'KFMod.DamTypeHuskGun' + alwaysScale(16) = class'KFMod.DamTypeCrossbow' + alwaysScale(17) = class'KFMod.DamTypeCrossbowHeadShot' + alwaysScale(18) = class'KFMod.DamTypeM99SniperRifle' + alwaysScale(19) = class'KFMod.DamTypeM99HeadShot' + alwaysScale(20) = class'KFMod.DamTypeShotgun' + alwaysScale(21) = class'KFMod.DamTypeNailGun' + alwaysScale(22) = class'KFMod.DamTypeDBShotgun' + alwaysScale(23) = class'KFMod.DamTypeKSGShotgun' + alwaysScale(24) = class'KFMod.DamTypeBenelli' + alwaysScale(25) = class'KFMod.DamTypeSPGrenade' + alwaysScale(26) = class'KFMod.DamTypeSPGrenadeImpact' + alwaysScale(27) = class'KFMod.DamTypeSeekerSixRocket' + alwaysScale(28) = class'KFMod.DamTypeSeekerRocketImpact' + alwaysScale(29) = class'KFMod.DamTypeSealSquealExplosion' + alwaysScale(30) = class'KFMod.DamTypeRocketImpact' + alwaysScale(31) = class'KFMod.DamTypeBlowerThrower' + alwaysScale(32) = class'KFMod.DamTypeSPShotgun' + alwaysScale(33) = class'KFMod.DamTypeZEDGun' + alwaysScale(34) = class'KFMod.DamTypeZEDGunMKII' +} \ No newline at end of file diff --git a/sources/FixInfiniteNades/FixInfiniteNades.uc b/sources/FixInfiniteNades/FixInfiniteNades.uc index 3d46a6e..c01602e 100644 --- a/sources/FixInfiniteNades/FixInfiniteNades.uc +++ b/sources/FixInfiniteNades/FixInfiniteNades.uc @@ -1,8 +1,6 @@ - /** - * This feature fixes a vulnerability in a code of `Frag` that can allow - * player to throw grenades even when he no longer has any. - * There's also no cooldowns on the throw, which can lead to a server crash. - * Copyright 2019 Anton Tarasenko +/** + * Config object for `FixInfiniteNades_Feature`. + * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -19,235 +17,34 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixInfiniteNades extends Feature +class FixInfiniteNades extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * It is possible to call `ServerThrow` function from client, - * forcing it to get executed on a server. This function consumes the grenade - * ammo and spawns a nade, but it doesn't check if player had any grenade ammo - * in the first place, allowing you him to throw however many grenades - * he wants. Moreover, unlike a regular throwing method, calling this function - * allows to spawn many grenades without any delay, - * which can lead to a server crash. - * - * This fix tracks every instance of `Frag` weapon that's responsible for - * throwing grenades and records how much ammo they have have. - * This is necessary, because whatever means we use, when we get a say in - * preventing grenade from spawning the ammo was already reduced. - * This means that we can't distinguished between a player abusing a bug by - * throwing grenade when he doesn't have necessary ammo and player throwing - * his last nade, as in both cases current ammo visible to us will be 0. - * Then, before every nade throw, it checks if player has enough ammo and - * blocks grenade from spawning if he doesn't. - * We change a `FireModeClass[0]` from `FragFire` to `FixedFragFire` and - * only call `super.DoFireEffect()` if we decide spawning grenade - * should be allowed. The side effect is a change in server's `FireModeClass`. - */ - -// Setting this flag to `true` will allow to throw grenades by calling -// `ServerThrow()` directly, as long as player has necessary ammo. -// This can allow some players to throw grenades much quicker than intended, -// so if you wish to prevent it, keep this flag set to `false`. -var private config const bool ignoreTossFlags; - -// Set to `true` when this fix is getting disabled to avoid replacing the -// fire class again. -var private bool shuttingDown; - -// Records how much ammo given frag grenade (`Frag`) has. -struct FragAmmoRecord -{ - // Reference to `Frag` - var public NativeActorRef fragReference; - var public int amount; -}; -var private array ammoRecords; - -protected function OnEnabled() -{ - local Frag nextFrag; - local LevelInfo level; - level = _.unreal.GetLevel(); - _.unreal.OnTick(self).connect = Tick; - shuttingDown = false; - // Find all frags, that spawned when this fix wasn't running. - foreach level.DynamicActors(class'KFMod.Frag', nextFrag) { - RegisterFrag(nextFrag); - } - RecreateFrags(); -} - -protected function OnDisabled() -{ - _.unreal.OnTick(self).Disconnect(); - shuttingDown = true; - RecreateFrags(); - ammoRecords.length = 0; -} +var public config bool ignoreTossFlags; -// Returns `true` when this feature is in the process of shutting down, -// which means nades' fire class should not be replaced. -public final function bool IsShuttingDown() +protected function AssociativeArray ToData() { - return shuttingDown; + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(P("ignoreTossFlags"), ignoreTossFlags, true); + return data; } -// Returns index of the connection corresponding to the given controller. -// Returns `-1` if no connection correspond to the given controller. -// Returns `-1` if given controller is equal to `none`. -private final function int GetAmmoIndex(Frag fragToCheck) +protected function FromData(AssociativeArray source) { - local int i; - if (fragToCheck == none) return -1; - - for (i = 0; i < ammoRecords.length; i += 1) - { - if (ammoRecords[i].fragReference.Get() == fragToCheck) { - return i; - } + if (source != none) { + ignoreTossFlags = source.GetBool(P("ignoreTossFlags"), true); } - return -1; } -// Recreates all the `Frag` actors, to change their fire mode mid-game. -private final function RecreateFrags() +protected function DefaultIt() { - local int i; - local float maxAmmo, currentAmmo; - local Frag newFrag, oldFrag; - local Pawn fragOwner; - local array oldRecords; - oldRecords = ammoRecords; - for (i = 0; i < oldRecords.length; i += 1) - { - // Check if we even need to recreate that instance of `Frag` - oldFrag = Frag(oldRecords[i].fragReference.Get()); - oldRecords[i].fragReference.FreeSelf(); - if (oldFrag == none) continue; - fragOwner = oldFrag.instigator; - if (fragOwner == none) continue; - // Recreate - oldFrag.Destroy(); - fragOwner.CreateInventory("KFMod.Frag"); - newFrag = GetPawnFrag(fragOwner); - // Restore ammo amount - if (newFrag != none) - { - newFrag.GetAmmoCount(maxAmmo, currentAmmo); - newFrag.AddAmmo(oldRecords[i].amount - Int(currentAmmo), 0); - } - } -} - -// Utility function to help find a `Frag` instance in a given pawn's inventory. -static private final function Frag GetPawnFrag(Pawn pawnWithFrag) -{ - local Frag foundFrag; - local Inventory invIter; - if (pawnWithFrag == none) return none; - invIter = pawnWithFrag.inventory; - while (invIter != none) - { - foundFrag = Frag(invIter); - if (foundFrag != none) { - return foundFrag; - } - invIter = invIter.inventory; - } - return none; -} - -// Utility function for extracting current ammo amount from a frag class. -private final function int GetFragAmmo(Frag fragReference) -{ - local float maxAmmo; - local float currentAmmo; - if (fragReference == none) return 0; - - fragReference.GetAmmoCount(maxAmmo, currentAmmo); - return Int(currentAmmo); -} - -// Attempts to add new `Frag` instance to our records. -public final function RegisterFrag(Frag newFrag) -{ - local int index; - local FragAmmoRecord newRecord; - index = GetAmmoIndex(newFrag); - if (index >= 0) return; - - newRecord.fragReference = _.unreal.ActorRef(newFrag); - newRecord.amount = GetFragAmmo(newFrag); - ammoRecords[ammoRecords.length] = newRecord; -} - -// This function tells our fix that there was a nade throw and we should -// reduce current `Frag` ammo in our records. -// Returns `true` if we had ammo for that, and `false` if we didn't. -public final function bool RegisterNadeThrow(Frag relevantFrag) -{ - if (CanThrowGrenade(relevantFrag)) - { - ReduceGrenades(relevantFrag); - return true; - } - return false; -} - -// Can we throw grenade according to our rules? -// A throw can be prevented if: -// - we think that player doesn't have necessary ammo; -// - Player isn't currently `tossing` a nade, -// meaning it was a direct call of `ServerThrow`. -private final function bool CanThrowGrenade(Frag fragToCheck) -{ - local int index; - // Nothing to check - if (fragToCheck == none) return false; - // No ammo - index = GetAmmoIndex(fragToCheck); - if (index < 0) return false; - if (ammoRecords[index].amount <= 0) return false; - // Not tossing - if (ignoreTossFlags) return true; - if (!fragToCheck.bTossActive || fragToCheck.bTossSpawned) return false; - return true; -} - -// Reduces recorded amount of ammo in our records for the given nade. -private final function ReduceGrenades(Frag relevantFrag) -{ - local int index; - index = GetAmmoIndex(relevantFrag); - if (index < 0) return; - ammoRecords[index].amount -= 1; -} - -private function Tick(float delta, float timeDilationCoefficient) -{ - local int i; - local Frag nextFrag; - // Update our ammo records with current, correct data. - while (i < ammoRecords.length) - { - nextFrag = Frag(ammoRecords[i].fragReference.Get()); - if (nextFrag != none) - { - ammoRecords[i].amount = GetFragAmmo(nextFrag); - i += 1; - } - else - { - ammoRecords[i].fragReference.FreeSelf(); - ammoRecords.Remove(i, 1); - } - } + ignoreTossFlags = true; } defaultproperties { + configName = "AcediaFixes" ignoreTossFlags = true - // Listeners - requiredListeners(0) = class'MutatorListener_FixInfiniteNades' } \ No newline at end of file diff --git a/sources/FixInfiniteNades/FixInfiniteNades_Feature.uc b/sources/FixInfiniteNades/FixInfiniteNades_Feature.uc new file mode 100644 index 0000000..af90ebb --- /dev/null +++ b/sources/FixInfiniteNades/FixInfiniteNades_Feature.uc @@ -0,0 +1,273 @@ + /** + * This feature fixes a vulnerability in a code of `Frag` that can allow + * player to throw grenades even when he no longer has any. + * There's also no cooldowns on the throw, which can lead to a server crash. + * Copyright 2019 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixInfiniteNades_Feature extends Feature; + +/** + * It is possible to call `ServerThrow` function from client, + * forcing it to get executed on a server. This function consumes the grenade + * ammo and spawns a nade, but it doesn't check if player had any grenade ammo + * in the first place, allowing you him to throw however many grenades + * he wants. Moreover, unlike a regular throwing method, calling this function + * allows to spawn many grenades without any delay, + * which can lead to a server crash. + * + * This fix tracks every instance of `Frag` weapon that's responsible for + * throwing grenades and records how much ammo they have have. + * This is necessary, because whatever means we use, when we get a say in + * preventing grenade from spawning the ammo was already reduced. + * This means that we can't distinguished between a player abusing a bug by + * throwing grenade when he doesn't have necessary ammo and player throwing + * his last nade, as in both cases current ammo visible to us will be 0. + * Then, before every nade throw, it checks if player has enough ammo and + * blocks grenade from spawning if he doesn't. + * We change a `FireModeClass[0]` from `FragFire` to `FixedFragFire` and + * only call `super.DoFireEffect()` if we decide spawning grenade + * should be allowed. The side effect is a change in server's `FireModeClass`. + */ + +// Setting this flag to `true` will allow to throw grenades by calling +// `ServerThrow()` directly, as long as player has necessary ammo. +// This can allow some players to throw grenades much quicker than intended, +// so if you wish to prevent it, keep this flag set to `false`. +var private /*config*/ bool ignoreTossFlags; + +// Set to `true` when this fix is getting disabled to avoid replacing the +// fire class again. +var private bool shuttingDown; + +// Records how much ammo given frag grenade (`Frag`) has. +struct FragAmmoRecord +{ + // Reference to `Frag` + var public NativeActorRef fragReference; + var public int amount; +}; +var private array ammoRecords; + +protected function OnEnabled() +{ + local Frag nextFrag; + local LevelInfo level; + level = _.unreal.GetLevel(); + _.unreal.OnTick(self).connect = Tick; + _.unreal.mutator.OnCheckReplacement(self).connect = CheckReplacement; + shuttingDown = false; + // Find all frags, that spawned when this fix wasn't running. + foreach level.DynamicActors(class'KFMod.Frag', nextFrag) { + RegisterFrag(nextFrag); + } + RecreateFrags(); +} + +protected function OnDisabled() +{ + _.unreal.OnTick(self).Disconnect(); + _.unreal.mutator.OnCheckReplacement(self).Disconnect(); + shuttingDown = true; + RecreateFrags(); + ammoRecords.length = 0; +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixInfiniteNades newConfig; + newConfig = FixInfiniteNades(config); + if (newConfig == none) { + return; + } + ignoreTossFlags = newConfig.ignoreTossFlags; +} + +private function bool CheckReplacement(Actor other, out byte isSuperRelevant) +{ + local Frag relevantFrag; + if (shuttingDown) { + return true; + } + // Handle detecting new frag (weapons that allows to throw nades) + relevantFrag = Frag(other); + if (relevantFrag != none) + { + RegisterFrag(relevantFrag); + relevantFrag.FireModeClass[0] = class'FixedFragFire'; + return true; + } + return true; +} + +// Returns index of the connection corresponding to the given controller. +// Returns `-1` if no connection correspond to the given controller. +// Returns `-1` if given controller is equal to `none`. +private final function int GetAmmoIndex(Frag fragToCheck) +{ + local int i; + if (fragToCheck == none) return -1; + + for (i = 0; i < ammoRecords.length; i += 1) + { + if (ammoRecords[i].fragReference.Get() == fragToCheck) { + return i; + } + } + return -1; +} + +// Recreates all the `Frag` actors, to change their fire mode mid-game. +private final function RecreateFrags() +{ + local int i; + local float maxAmmo, currentAmmo; + local Frag newFrag, oldFrag; + local Pawn fragOwner; + local array oldRecords; + oldRecords = ammoRecords; + for (i = 0; i < oldRecords.length; i += 1) + { + // Check if we even need to recreate that instance of `Frag` + oldFrag = Frag(oldRecords[i].fragReference.Get()); + oldRecords[i].fragReference.FreeSelf(); + if (oldFrag == none) continue; + fragOwner = oldFrag.instigator; + if (fragOwner == none) continue; + // Recreate + oldFrag.Destroy(); + fragOwner.CreateInventory("KFMod.Frag"); + newFrag = GetPawnFrag(fragOwner); + // Restore ammo amount + if (newFrag != none) + { + newFrag.GetAmmoCount(maxAmmo, currentAmmo); + newFrag.AddAmmo(oldRecords[i].amount - Int(currentAmmo), 0); + } + } +} + +// Utility function to help find a `Frag` instance in a given pawn's inventory. +static private final function Frag GetPawnFrag(Pawn pawnWithFrag) +{ + local Frag foundFrag; + local Inventory invIter; + if (pawnWithFrag == none) return none; + invIter = pawnWithFrag.inventory; + while (invIter != none) + { + foundFrag = Frag(invIter); + if (foundFrag != none) { + return foundFrag; + } + invIter = invIter.inventory; + } + return none; +} + +// Utility function for extracting current ammo amount from a frag class. +private final function int GetFragAmmo(Frag fragReference) +{ + local float maxAmmo; + local float currentAmmo; + if (fragReference == none) return 0; + + fragReference.GetAmmoCount(maxAmmo, currentAmmo); + return Int(currentAmmo); +} + +// Attempts to add new `Frag` instance to our records. +public final function RegisterFrag(Frag newFrag) +{ + local int index; + local FragAmmoRecord newRecord; + index = GetAmmoIndex(newFrag); + if (index >= 0) return; + + newRecord.fragReference = _.unreal.ActorRef(newFrag); + newRecord.amount = GetFragAmmo(newFrag); + ammoRecords[ammoRecords.length] = newRecord; +} + +// This function tells our fix that there was a nade throw and we should +// reduce current `Frag` ammo in our records. +// Returns `true` if we had ammo for that, and `false` if we didn't. +public final function bool RegisterNadeThrow(Frag relevantFrag) +{ + if (CanThrowGrenade(relevantFrag)) + { + ReduceGrenades(relevantFrag); + return true; + } + return false; +} + +// Can we throw grenade according to our rules? +// A throw can be prevented if: +// - we think that player doesn't have necessary ammo; +// - Player isn't currently `tossing` a nade, +// meaning it was a direct call of `ServerThrow`. +private final function bool CanThrowGrenade(Frag fragToCheck) +{ + local int index; + // Nothing to check + if (fragToCheck == none) return false; + // No ammo + index = GetAmmoIndex(fragToCheck); + if (index < 0) return false; + if (ammoRecords[index].amount <= 0) return false; + // Not tossing + if (ignoreTossFlags) return true; + if (!fragToCheck.bTossActive || fragToCheck.bTossSpawned) return false; + return true; +} + +// Reduces recorded amount of ammo in our records for the given nade. +private final function ReduceGrenades(Frag relevantFrag) +{ + local int index; + index = GetAmmoIndex(relevantFrag); + if (index < 0) return; + ammoRecords[index].amount -= 1; +} + +private function Tick(float delta, float timeDilationCoefficient) +{ + local int i; + local Frag nextFrag; + // Update our ammo records with current, correct data. + while (i < ammoRecords.length) + { + nextFrag = Frag(ammoRecords[i].fragReference.Get()); + if (nextFrag != none) + { + ammoRecords[i].amount = GetFragAmmo(nextFrag); + i += 1; + } + else + { + ammoRecords[i].fragReference.FreeSelf(); + ammoRecords.Remove(i, 1); + } + } +} + +defaultproperties +{ + configClass = class'FixInfiniteNades' + ignoreTossFlags = true +} \ No newline at end of file diff --git a/sources/FixInfiniteNades/FixedFragFire.uc b/sources/FixInfiniteNades/FixedFragFire.uc index 702696f..fe7cba6 100644 --- a/sources/FixInfiniteNades/FixedFragFire.uc +++ b/sources/FixInfiniteNades/FixedFragFire.uc @@ -23,8 +23,9 @@ class FixedFragFire extends KFMod.FragFire; function DoFireEffect() { - local FixInfiniteNades nadeFix; - nadeFix = FixInfiniteNades(class'FixInfiniteNades'.static.GetInstance()); + local FixInfiniteNades_Feature nadeFix; + nadeFix = FixInfiniteNades_Feature( + class'FixInfiniteNades_Feature'.static.GetInstance()); if (nadeFix == none || nadeFix.RegisterNadeThrow(Frag(weapon))) { super.DoFireEffect(); } diff --git a/sources/FixInfiniteNades/MutatorListener_FixInfiniteNades.uc b/sources/FixInfiniteNades/MutatorListener_FixInfiniteNades.uc deleted file mode 100644 index 255b1c7..0000000 --- a/sources/FixInfiniteNades/MutatorListener_FixInfiniteNades.uc +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Overloaded mutator events listener to catch - * new `Frag` weapons and `Nade` projectiles. - * Copyright 2019 Anton Tarasenko - *------------------------------------------------------------------------------ - * This file is part of Acedia. - * - * Acedia is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Acedia is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Acedia. If not, see . - */ -class MutatorListener_FixInfiniteNades extends MutatorListenerBase - abstract; - -static function bool CheckReplacement(Actor other, out byte isSuperRelevant) -{ - local Frag relevantFrag; - local FixInfiniteNades nadeFix; - nadeFix = FixInfiniteNades(class'FixInfiniteNades'.static.GetInstance()); - if (nadeFix == none) return true; - if (nadeFix.IsShuttingDown()) return true; - - // Handle detecting new frag (weapons that allows to throw nades) - relevantFrag = Frag(other); - if (relevantFrag != none) - { - nadeFix.RegisterFrag(relevantFrag); - relevantFrag.FireModeClass[0] = class'FixedFragFire'; - return true; - } - return true; -} - -defaultproperties -{ -} \ No newline at end of file diff --git a/sources/FixInventoryAbuse/FixInventoryAbuse.uc b/sources/FixInventoryAbuse/FixInventoryAbuse.uc index 083f8fe..f8c7ac9 100644 --- a/sources/FixInventoryAbuse/FixInventoryAbuse.uc +++ b/sources/FixInventoryAbuse/FixInventoryAbuse.uc @@ -1,16 +1,6 @@ /** - * This feature addressed two inventory issues: - * 1. Players carrying amount of weapons that shouldn't be allowed by the - * weight limit. - * 2. Players carrying two variants of the same gun. - * For example carrying both M32 and camo M32. - * Single and dual version of the same weapon are also considered - * the same gun, so you can't carry both MK23 and dual MK23 or - * dual handcannons and golden handcannon. - * - * It fixes them by doing repeated checks to find violations of those rules - * and destroys all droppable weapons of people that use this exploit. - * Copyright 2020 Anton Tarasenko + * Config object for `FixInventoryAbuse_Feature`. + * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -27,192 +17,91 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixInventoryAbuse extends Feature - config(AcediaFixes); - -// How often (in seconds) should we do our inventory validations? -// We shouldn't really worry about performance, but there's also no need to -// do this check too often. -var private config const float checkInterval; - -var private Timer checkTimer; - -struct DualiesPair -{ - var class single; - var class dual; -}; -// For this fix to properly work, this array must contain an entry for -// every dual weapon in the game (like pistols, with single and dual versions). -// It's made configurable in case of custom dual weapons. -var private config const array dualiesClasses; - -protected function OnEnabled() -{ - local float actualInterval; - actualInterval = checkInterval; - if (actualInterval <= 0) { - actualInterval = 0.25; - } - checkTimer = _.time.StartTimer(actualInterval, true); - checkTimer.OnElapsed(self).connect = Timer; -} +class FixInventoryAbuse extends FeatureConfig + perobjectconfig + config(AcediaFixes) + dependson(FixInventoryAbuse_Feature); -protected function OnDisabled() -{ - _.memory.Free(checkTimer); -} - -// Did player with this controller contribute to the latest dosh generation? -private final function bool IsWeightLimitViolated(KFHumanPawn playerPawn) -{ - if (playerPawn == none) return false; - return (playerPawn.currentWeight > playerPawn.maxCarryWeight); -} +var public config float checkInterval; +var public config array dualiesClasses; -// Returns a root pickup class. -// For non-dual weapons, root class is defined as either: -// 1. the first variant (reskin), if there are variants for that weapon; -// 2. and as the class itself, if there are no variants. -// For dual weapons (all dual pistols) root class is defined as -// a root of their single version. -// This definition is useful because: -// ~ Vanilla game rules are such that player can only have two weapons -// in the inventory if they have different roots; -// ~ Root is easy to find. -private final function class GetRootPickupClass(KFWeapon weapon) +protected function AssociativeArray ToData() { - local int i; - local class root; - if (weapon == none) return none; - // Start with a pickup of the given weapons - root = class(weapon.default.pickupClass); - if (root == none) return none; - - // In case it's a dual version - find corresponding single pickup class - // (it's root would be the same). + local int i; + local DynamicArray pairsArray; + local AssociativeArray data, pair; + data = _.collections.EmptyAssociativeArray(); + data.SetFloat(P("checkInterval"), checkInterval, true); + pairsArray = _.collections.EmptyDynamicArray(); for (i = 0; i < dualiesClasses.length; i += 1) { - if (dualiesClasses[i].dual == root) - { - root = dualiesClasses[i].single; - break; - } - } - // Take either first variant class or the class itself, - - // it's going to be root by definition. - if (root.default.variantClasses.length > 0) - { - root = class(root.default.variantClasses[0]); + pair = _.collections.EmptyAssociativeArray(); + pair.SetItem( P("single"), + _.text.FromString(string(dualiesClasses[i].single))); + pair.SetItem( P("dual"), + _.text.FromString(string(dualiesClasses[i].dual))); + pairsArray.AddItem(pair); } - return root; + data.SetItem(P("dualiesClasses"), pairsArray); + return data; } -// Returns `true` if passed pawn has two weapons that are just variants of -// each other (they have the same root, see `GetRootPickupClass()`). -private final function bool HasDuplicateGuns(KFHumanPawn playerPawn) +protected function FromData(AssociativeArray source) { - local int i, j; - local Inventory inv; - local KFWeapon nextWeapon; - local class rootClass; - local array< class > rootList; - if (playerPawn == none) return false; - - // First find a root for every weapon in the pawn's inventory. - for (inv = playerPawn.inventory; inv != none; inv = inv.inventory) - { - nextWeapon = KFWeapon(inv); - if (nextWeapon == none) continue; - if (nextWeapon.bKFNeverThrow) continue; - rootClass = GetRootPickupClass(nextWeapon); - if (rootClass != none) { - rootList[rootList.length] = rootClass; - } + local int i; + local DynamicArray pairsArray; + local AssociativeArray loadedPair; + local FixInventoryAbuse_Feature.DualiesPair newPair; + if (source == none) { + return; } - // Then just check obtained roots for duplicates. - for (i = 0; i < rootList.length; i += 1) - { - for (j = i + 1; j < rootList.length; j += 1) - { - if (rootList[i] == rootList[j]) { - return true; - } - } + checkInterval = source.GetFloat(P("checkInterval"), 0.25); + pairsArray = source.GetDynamicArray(P("dualiesClasses")); + dualiesClasses.length = 0; + if (pairsArray == none) { + return; } - return false; -} - -private final function Vector DropWeapon(KFWeapon weaponToDrop) -{ - local Vector x, y, z; - local Vector weaponVelocity; - local Vector dropLocation; - local KFHumanPawn playerPawn; - if (weaponToDrop == none) return Vect(0, 0, 0); - playerPawn = KFHumanPawn(weaponToDrop.instigator); - if (playerPawn == none) return Vect(0, 0, 0); - - // Calculations from `PlayerController.ServerThrowWeapon()` - weaponVelocity = Vector(playerPawn.GetViewRotation()); - weaponVelocity *= (playerPawn.velocity dot weaponVelocity) + 150; - weaponVelocity += Vect(0, 0, 100); - // Calculations from `Pawn.TossWeapon()` - GetAxes(playerPawn.rotation, x, y, z); - dropLocation = playerPawn.location + 0.8 * playerPawn.collisionRadius * x - - 0.5 * playerPawn.collisionRadius * y; - // Do the drop - weaponToDrop.velocity = weaponVelocity; - weaponToDrop.DropFrom(dropLocation); -} - -// Kill the gun devil! -private final function DropEverything(KFHumanPawn playerPawn) -{ - local int i; - local Inventory inv; - local KFWeapon nextWeapon; - local array weaponList; - if (playerPawn == none) return; - // Going through the linked list while removing items can be tricky, - // so just find all weapons first. - for (inv = playerPawn.inventory; inv != none; inv = inv.inventory) + for (i = 0; i < pairsArray.GetLength(); i += 1) { - nextWeapon = KFWeapon(inv); - if (nextWeapon == none) continue; - if (nextWeapon.bKFNeverThrow) continue; - weaponList[weaponList.length] = nextWeapon; - } - // And destroy them later. - for(i = 0; i < weaponList.length; i += 1) { - DropWeapon(weaponList[i]); + loadedPair = pairsArray.GetAssociativeArray(i); + if (loadedPair == none) continue; + + newPair.single = class( + _.memory.LoadClass(loadedPair.GetText(P("single"))) ); + newPair.dual = class( + _.memory.LoadClass(loadedPair.GetText(P("dual"))) ); + dualiesClasses[dualiesClasses.length] = newPair; } } -private function Timer(Timer source) +protected function DefaultIt() { - local int i; - local KFHumanPawn nextPawn; - local ConnectionService service; - local array connections; - service = ConnectionService(class'ConnectionService'.static.GetInstance()); - if (service == none) return; - - connections = service.GetActiveConnections(); - for (i = 0; i < connections.length; i += 1) - { - nextPawn = none; - if (connections[i].controllerReference != none) { - nextPawn = KFHumanPawn(connections[i].controllerReference.pawn); - } - if (IsWeightLimitViolated(nextPawn) || HasDuplicateGuns(nextPawn)) { - DropEverything(nextPawn); - } - } + local FixInventoryAbuse_Feature.DualiesPair newPair; + checkInterval = 0.25; + dualiesClasses.length = 0; + newPair.single = class'KFMod.SinglePickup'; + newPair.dual = class'KFMod.DualiesPickup'; + dualiesClasses[dualiesClasses.length] = newPair; + newPair.single = class'KFMod.Magnum44Pickup'; + newPair.dual = class'KFMod.Dual44MagnumPickup'; + dualiesClasses[dualiesClasses.length] = newPair; + newPair.single = class'KFMod.MK23Pickup'; + newPair.dual = class'KFMod.DualMK23Pickup'; + dualiesClasses[dualiesClasses.length] = newPair; + newPair.single = class'KFMod.DeaglePickup'; + newPair.dual = class'KFMod.DualDeaglePickup'; + dualiesClasses[dualiesClasses.length] = newPair; + newPair.single = class'KFMod.GoldenDeaglePickup'; + newPair.dual = class'KFMod.GoldenDualDeaglePickup'; + dualiesClasses[dualiesClasses.length] = newPair; + newPair.single = class'KFMod.FlareRevolverPickup'; + newPair.dual = class'KFMod.DualFlareRevolverPickup'; + dualiesClasses[dualiesClasses.length] = newPair; } defaultproperties { + configName = "AcediaFixes" checkInterval = 0.25 dualiesClasses(0)=(single=class'KFMod.SinglePickup',dual=class'KFMod.DualiesPickup') dualiesClasses(1)=(single=class'KFMod.Magnum44Pickup',dual=class'KFMod.Dual44MagnumPickup') diff --git a/sources/FixInventoryAbuse/FixInventoryAbuse_Feature.uc b/sources/FixInventoryAbuse/FixInventoryAbuse_Feature.uc new file mode 100644 index 0000000..b277302 --- /dev/null +++ b/sources/FixInventoryAbuse/FixInventoryAbuse_Feature.uc @@ -0,0 +1,237 @@ +/** + * This feature addressed two inventory issues: + * 1. Players carrying amount of weapons that shouldn't be allowed by the + * weight limit. + * 2. Players carrying two variants of the same gun. + * For example carrying both M32 and camo M32. + * Single and dual version of the same weapon are also considered + * the same gun, so you can't carry both MK23 and dual MK23 or + * dual handcannons and golden handcannon. + * + * It fixes them by doing repeated checks to find violations of those rules + * and destroys all droppable weapons of people that use this exploit. + * Copyright 2020 - 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixInventoryAbuse_Feature extends Feature; + +var private Timer checkTimer; + +// How often (in seconds) should we do our inventory validations? +// We shouldn't really worry about performance, but there's also no need to +// do this check too often. +var private /*config*/ float checkInterval; + +struct DualiesPair +{ + var class single; + var class dual; +}; +// For this fix to properly work, this array must contain an entry for +// every dual weapon in the game (like pistols, with single and dual versions). +// It's made configurable in case of custom dual weapons. +var private /*config*/ array dualiesClasses; + +protected function OnEnabled() +{ + local float actualInterval; + actualInterval = checkInterval; + if (actualInterval <= 0) { + actualInterval = 0.25; + } + checkTimer = _.time.StartTimer(actualInterval, true); + checkTimer.OnElapsed(self).connect = Timer; +} + +protected function OnDisabled() +{ + _.memory.Free(checkTimer); +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixInventoryAbuse newConfig; + newConfig = FixInventoryAbuse(config); + if (newConfig == none) { + return; + } + checkInterval = newConfig.checkInterval; + dualiesClasses = newConfig.dualiesClasses; + if (checkTimer != none) { + checkTimer.SetInterval(checkInterval); + } +} + +// Did player with this controller contribute to the latest dosh generation? +private final function bool IsWeightLimitViolated(KFHumanPawn playerPawn) +{ + if (playerPawn == none) return false; + return (playerPawn.currentWeight > playerPawn.maxCarryWeight); +} + +// Returns a root pickup class. +// For non-dual weapons, root class is defined as either: +// 1. the first variant (reskin), if there are variants for that weapon; +// 2. and as the class itself, if there are no variants. +// For dual weapons (all dual pistols) root class is defined as +// a root of their single version. +// This definition is useful because: +// ~ Vanilla game rules are such that player can only have two weapons +// in the inventory if they have different roots; +// ~ Root is easy to find. +private final function class GetRootPickupClass(KFWeapon weapon) +{ + local int i; + local class root; + if (weapon == none) return none; + // Start with a pickup of the given weapons + root = class(weapon.default.pickupClass); + if (root == none) return none; + + // In case it's a dual version - find corresponding single pickup class + // (it's root would be the same). + for (i = 0; i < dualiesClasses.length; i += 1) + { + if (dualiesClasses[i].dual == root) + { + root = dualiesClasses[i].single; + break; + } + } + // Take either first variant class or the class itself, - + // it's going to be root by definition. + if (root.default.variantClasses.length > 0) + { + root = class(root.default.variantClasses[0]); + } + return root; +} + +// Returns `true` if passed pawn has two weapons that are just variants of +// each other (they have the same root, see `GetRootPickupClass()`). +private final function bool HasDuplicateGuns(KFHumanPawn playerPawn) +{ + local int i, j; + local Inventory inv; + local KFWeapon nextWeapon; + local class rootClass; + local array< class > rootList; + if (playerPawn == none) return false; + + // First find a root for every weapon in the pawn's inventory. + for (inv = playerPawn.inventory; inv != none; inv = inv.inventory) + { + nextWeapon = KFWeapon(inv); + if (nextWeapon == none) continue; + if (nextWeapon.bKFNeverThrow) continue; + rootClass = GetRootPickupClass(nextWeapon); + if (rootClass != none) { + rootList[rootList.length] = rootClass; + } + } + // Then just check obtained roots for duplicates. + for (i = 0; i < rootList.length; i += 1) + { + for (j = i + 1; j < rootList.length; j += 1) + { + if (rootList[i] == rootList[j]) { + return true; + } + } + } + return false; +} + +private final function Vector DropWeapon(KFWeapon weaponToDrop) +{ + local Vector x, y, z; + local Vector weaponVelocity; + local Vector dropLocation; + local KFHumanPawn playerPawn; + if (weaponToDrop == none) return Vect(0, 0, 0); + playerPawn = KFHumanPawn(weaponToDrop.instigator); + if (playerPawn == none) return Vect(0, 0, 0); + + // Calculations from `PlayerController.ServerThrowWeapon()` + weaponVelocity = Vector(playerPawn.GetViewRotation()); + weaponVelocity *= (playerPawn.velocity dot weaponVelocity) + 150; + weaponVelocity += Vect(0, 0, 100); + // Calculations from `Pawn.TossWeapon()` + GetAxes(playerPawn.rotation, x, y, z); + dropLocation = playerPawn.location + 0.8 * playerPawn.collisionRadius * x - + 0.5 * playerPawn.collisionRadius * y; + // Do the drop + weaponToDrop.velocity = weaponVelocity; + weaponToDrop.DropFrom(dropLocation); +} + +// Kill the gun devil! +private final function DropEverything(KFHumanPawn playerPawn) +{ + local int i; + local Inventory inv; + local KFWeapon nextWeapon; + local array weaponList; + if (playerPawn == none) return; + // Going through the linked list while removing items can be tricky, + // so just find all weapons first. + for (inv = playerPawn.inventory; inv != none; inv = inv.inventory) + { + nextWeapon = KFWeapon(inv); + if (nextWeapon == none) continue; + if (nextWeapon.bKFNeverThrow) continue; + weaponList[weaponList.length] = nextWeapon; + } + // And destroy them later. + for(i = 0; i < weaponList.length; i += 1) { + DropWeapon(weaponList[i]); + } +} + +private function Timer(Timer source) +{ + local int i; + local KFHumanPawn nextPawn; + local ConnectionService service; + local array connections; + service = ConnectionService(class'ConnectionService'.static.GetInstance()); + if (service == none) return; + + connections = service.GetActiveConnections(); + for (i = 0; i < connections.length; i += 1) + { + nextPawn = none; + if (connections[i].controllerReference != none) { + nextPawn = KFHumanPawn(connections[i].controllerReference.pawn); + } + if (IsWeightLimitViolated(nextPawn) || HasDuplicateGuns(nextPawn)) { + DropEverything(nextPawn); + } + } +} + +defaultproperties +{ + configClass = class'FixInventoryAbuse' + checkInterval = 0.25 + dualiesClasses(0)=(single=class'KFMod.SinglePickup',dual=class'KFMod.DualiesPickup') + dualiesClasses(1)=(single=class'KFMod.Magnum44Pickup',dual=class'KFMod.Dual44MagnumPickup') + dualiesClasses(2)=(single=class'KFMod.MK23Pickup',dual=class'KFMod.DualMK23Pickup') + dualiesClasses(3)=(single=class'KFMod.DeaglePickup',dual=class'KFMod.DualDeaglePickup') + dualiesClasses(4)=(single=class'KFMod.GoldenDeaglePickup',dual=class'KFMod.GoldenDualDeaglePickup') + dualiesClasses(5)=(single=class'KFMod.FlareRevolverPickup',dual=class'KFMod.DualFlareRevolverPickup') +} \ No newline at end of file diff --git a/sources/FixLogSpam/FixLogSpam.uc b/sources/FixLogSpam/FixLogSpam.uc index 2773d9f..e21e956 100644 --- a/sources/FixLogSpam/FixLogSpam.uc +++ b/sources/FixLogSpam/FixLogSpam.uc @@ -1,16 +1,5 @@ /** - * This feature fixes different instances of log spam by the killing floor - * with various warnings and errors. Some of them have actual underlying bugs - * that need to be fixed, but a lot seem to be just a byproduct of dead and - * abandoned features or simple negligence. - * Whatever the case, now that TWI will no longer make any new changes to - * the game a lot of them do not serve any purpose and simply pollute - * log files. We try to get rid of at least some of them. - * Since changes we make do not actually have gameplay effect and - * are more aimed at convenience of server owners, our philosophy with the - * changes will be to avoid solutions that are way too "hacky" and prefer some - * message spam getting through to the possibility of some unexpected gameplay - * effects as far as vanilla game is concerned. + * Config object for `FixLogSpam_Feature`. * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. @@ -28,44 +17,40 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixLogSpam extends Feature +class FixLogSpam extends FeatureConfig + perobjectconfig config(AcediaFixes); -// This is responsible for fixing log spam due to picking up dropped -// weapons without set `inventory` variable. -var private config const bool fixPickupSpam; -var private HelperPickup helperPickupSpam; +var public config bool fixPickupSpam; +var public config bool fixTraderSpam; -var private config const bool fixTraderSpam; -var private HelperTrader helperTraderSpam; - -protected function OnEnabled() +protected function AssociativeArray ToData() { - if (fixPickupSpam) { - helperPickupSpam = HelperPickup(_.memory.Allocate(class'HelperPickup')); - } - if (fixTraderSpam) { - helperTraderSpam = HelperTrader(_.memory.Allocate(class'HelperTrader')); - } + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(P("fixPickupSpam"), fixPickupSpam, true); + data.SetBool(P("fixTraderSpam"), fixTraderSpam, true); + return data; } -protected function OnDisabled() +protected function FromData(AssociativeArray source) { - _.memory.Free(helperPickupSpam); - helperPickupSpam = none; - _.memory.Free(helperTraderSpam); - helperTraderSpam = none; + if (source != none) + { + fixPickupSpam = source.GetBool(P("fixPickupSpam"), true); + fixTraderSpam = source.GetBool(P("fixTraderSpam"), true); + } } -public function Tick(float delta) +protected function DefaultIt() { - if (helperPickupSpam != none) { - helperPickupSpam.Tick(); - } + fixPickupSpam = true; + fixTraderSpam = true; } defaultproperties { + configName = "AcediaFixes" fixPickupSpam = true fixTraderSpam = true } \ No newline at end of file diff --git a/sources/FixLogSpam/FixLogSpam_Feature.uc b/sources/FixLogSpam/FixLogSpam_Feature.uc new file mode 100644 index 0000000..0ed0ae9 --- /dev/null +++ b/sources/FixLogSpam/FixLogSpam_Feature.uc @@ -0,0 +1,93 @@ +/** + * This feature fixes different instances of log spam by the killing floor + * with various warnings and errors. Some of them have actual underlying bugs + * that need to be fixed, but a lot seem to be just a byproduct of dead and + * abandoned features or simple negligence. + * Whatever the case, now that TWI will no longer make any new changes to + * the game a lot of them do not serve any purpose and simply pollute + * log files. We try to get rid of at least some of them. + * Since changes we make do not actually have gameplay effect and + * are more aimed at convenience of server owners, our philosophy with the + * changes will be to avoid solutions that are way too "hacky" and prefer some + * message spam getting through to the possibility of some unexpected gameplay + * effects as far as vanilla game is concerned. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixLogSpam_Feature extends Feature; + +// This is responsible for fixing log spam due to picking up dropped +// weapons without set `inventory` variable. +var private /*config*/ bool fixPickupSpam; +var private HelperPickup helperPickupSpam; + +var private /*config*/ bool fixTraderSpam; +var private HelperTrader helperTraderSpam; + +protected function OnEnabled() +{ + if (fixPickupSpam) { + helperPickupSpam = HelperPickup(_.memory.Allocate(class'HelperPickup')); + } + if (fixTraderSpam) { + helperTraderSpam = HelperTrader(_.memory.Allocate(class'HelperTrader')); + } +} + +protected function OnDisabled() +{ + _.memory.Free(helperPickupSpam); + helperPickupSpam = none; + _.memory.Free(helperTraderSpam); + helperTraderSpam = none; +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixLogSpam newConfig; + newConfig = FixLogSpam(config); + if (newConfig == none) { + return; + } + // Pickup spam + fixPickupSpam = newConfig.fixPickupSpam; + if (fixPickupSpam && helperPickupSpam == none) { + helperPickupSpam = HelperPickup(_.memory.Allocate(class'HelperPickup')); + } + if (!fixPickupSpam && helperPickupSpam != none) + { + _.memory.Free(helperPickupSpam); + helperPickupSpam = none; + } + // Trader fixTraderSpam + fixTraderSpam = newConfig.fixTraderSpam; + if (fixTraderSpam && helperTraderSpam == none) { + helperTraderSpam = HelperTrader(_.memory.Allocate(class'HelperPickup')); + } + if (!fixTraderSpam && helperTraderSpam != none) + { + _.memory.Free(helperTraderSpam); + helperTraderSpam = none; + } +} + +defaultproperties +{ + configClass = class'FixLogSpam' + fixPickupSpam = true + fixTraderSpam = true +} \ No newline at end of file diff --git a/sources/FixLogSpam/SpamPickup/HelperPickup.uc b/sources/FixLogSpam/SpamPickup/HelperPickup.uc index 57409bd..82e84b9 100644 --- a/sources/FixLogSpam/SpamPickup/HelperPickup.uc +++ b/sources/FixLogSpam/SpamPickup/HelperPickup.uc @@ -18,8 +18,7 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class HelperPickup extends AcediaObject - config(AcediaFixes); +class HelperPickup extends AcediaObject; /** * `KFWeaponPickup` class is responsible for spamming log with @@ -90,7 +89,9 @@ protected function Constructor() // (and force additional pickup fix update) _.unreal.gameRules.OnOverridePickupQuery(self).connect = PickupQuery; // To detect newly spawned pickups - class'MutatorListener_FixLogSpam_Pickup'.static.SetActive(true); + _.unreal.mutator.OnCheckReplacement(self).connect = CheckReplacement; + // For updating pickups as soon as possible + _.unreal.OnTick(self).connect = Tick; // Find all `KFWeaponPickup`s laying around on the map, // so that we can fix preexisting ones too. // But add them to pending list in a freaky case this `HealperPickup` @@ -119,7 +120,33 @@ protected function Finalizer() recordedPickups.length = 0; pendingPickups.length = 0; _.unreal.gameRules.OnOverridePickupQuery(self).Disconnect(); - class'MutatorListener_FixLogSpam_Pickup'.static.SetActive(false); + _.unreal.mutator.OnCheckReplacement(self).Disconnect(); + _.unreal.OnTick(self).Disconnect(); +} + +function bool PickupQuery( + Pawn toucher, + Pickup touchedPickup, + out byte allowPickup) +{ + UpdatePickups(); + return false; +} + +private function bool CheckReplacement(Actor other, out byte isSuperRelevant) +{ + local KFWeaponPickup otherPickup; + otherPickup = KFWeaponPickup(other); + if (otherPickup != none) { + HandlePickup(otherPickup); + } + return true; +} + +private function Tick(float delta, float timeDilationCoefficient) +{ + CleanRecordedPickups(); + UpdatePickups(); } public final static function HelperPickup GetInstance() @@ -214,21 +241,6 @@ private final function UpdatePickups() pendingPickups.length = 0; } -function bool PickupQuery( - Pawn toucher, - Pickup touchedPickup, - out byte allowPickup) -{ - UpdatePickups(); - return false; -} - -public final function Tick() -{ - CleanRecordedPickups(); - UpdatePickups(); -} - defaultproperties { } \ No newline at end of file diff --git a/sources/FixLogSpam/SpamPickup/MutatorListener_FixLogSpam_Pickup.uc b/sources/FixLogSpam/SpamPickup/MutatorListener_FixLogSpam_Pickup.uc deleted file mode 100644 index f47ef5b..0000000 --- a/sources/FixLogSpam/SpamPickup/MutatorListener_FixLogSpam_Pickup.uc +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Overloaded mutator events listener to catch and, possibly, - * prevent spawning `KFWeaponPickup` for fixing log spam related to them. - * Copyright 2021 Anton Tarasenko - *------------------------------------------------------------------------------ - * This file is part of Acedia. - * - * Acedia is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Acedia is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Acedia. If not, see . - */ -class MutatorListener_FixLogSpam_Pickup extends MutatorListenerBase - abstract; - -static function bool CheckReplacement(Actor other, out byte isSuperRelevant) -{ - local HelperPickup helper; - local KFWeaponPickup otherPickup; - otherPickup = KFWeaponPickup(other); - if (otherPickup == none) return true; - helper = class'HelperPickup'.static.GetInstance(); - if (helper == none) return true; - - helper.HandlePickup(otherPickup); - return true; -} - -defaultproperties -{ - relatedEvents = class'MutatorEvents' -} \ No newline at end of file diff --git a/sources/FixPipes/FixPipes.uc b/sources/FixPipes/FixPipes.uc index e5a88e1..8dc7bb8 100644 --- a/sources/FixPipes/FixPipes.uc +++ b/sources/FixPipes/FixPipes.uc @@ -1,11 +1,5 @@ /** - * This feature addresses several bugs related to pipe bombs: - * 1. Gaining extra explosive damage by shooting pipes with - * a high fire rate weapon; - * 2. Other players exploding one's pipes; - * 3. Corpses and story NPCs exploding nearby pipes; - * 4. Pipes being stuck in places where they cannot detect nearby - * enemies and, therefore, not exploding. + * Config object for `FixPipes_Feature`. * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. @@ -23,428 +17,55 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixPipes extends Feature +class FixPipes extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * There are two main culprits for the issues we are interested in: - * `TakeDamage()` and `Timer()` methods. `TakeDamage()` lacks sufficient checks - * for instigator allows for ways to damage it, causing explosion. It also - * lacks any checks for whether pipe bom already exploded, allowing players to - * damage it several time before it's destroyed, causing explosion every time. - * `Timer()` method simply contains a porximity check for hostile pawns that - * wrongfully detects certain actor (like `KF_StoryNPC` or players' corpses) - * and cannot detect zeds around itself if pipe is placed in a way that - * prevents it from having a direct line of sight towards zeds. - * To fix our issues we have to somehow overload both of these methods, - * which is impossible while keeping Acedia server-side. So we will have to - * do some hacks. - * - * `TakeDamage()`. To override this method's behavior we will remove actor - * collision from pipe bombs, preventing it from being directly damaged, then - * add an `ExtendedZCollision` subclass around it to catch `TakeDamage()` - * events that would normally target pipe bombs themselves. There we can add - * additional checks to help us decide whether pipe bombs should actually - * be damaged. - * - * `Timer()`. It would be simple to take control of this method by, say, - * spamming `SetTimer(0.0, false)` every tick and then executing our own logic - * elsewhere. However we only care about changin `Timer()`'s logic when pipe - * bomb attempts to detect cnearby enemies and do not want to manage the rest - * of `Timer()`'s logic. - * Pipe bombs work like this: after being thrown they fall until they land, - * invoking `Landed()` and `HitWall()` calls that start up the timer. Then - * timer uses `ArmingCountDown` variable to count down the time for pipe to - * "arm", that is, start functioning. It's after that that pipe starts to try - * and detect nearby enemies until it decides to start exploding sequence, - * instantly exploded with damage or disintegrated by siren's scream. - * We want to catch the moment when `ArmingCountDown` reaches zero and - * only then disable the timer. Then case we'll only need to reimplement logic - * that takes care of enemy detection. We also do not need to constantly - * re-reset the timer, since any code that will restart it would mean that - * pipe bomb no longer needs to look for enemies. - * When we detect enough enemies nearby or a change in pipe bomb's state - * due to outside factors (being exploded or disintegrated) we simply need to - * start it's the timer (if it was not done for us already). - */ - -// NOTE #1: setting either of `preventMassiveDamage` or -// `preventSuspiciousDamage` might change how and where pipes might fall on -// the ground (for example, pipe hats shoul not work anymore). In most cases, -// however, there should be no difference from vanilla gameplay. -// Setting this to `true` fixes a mechanic that allows pipes to deal extra -// damage when detonated with a high fire rate weapons: pipes will now always -// deal the same amount of damage. -var public const config bool preventMassiveDamage; -// It's possible for teammates to explode one's pipe under certain -// circumstances. Setting this setting to `true` will prevent that -// from happening. -var public const config bool preventSuspiciousDamage; -// Setting this to `true` will prevent pipe bombs from being detonated by -// the nearby corpses on other player. -var public const config bool preventCorpseDetonation; -// Setting this to `true` will prevents pipe bombs from being detonated by -// nearby KFO NPCs (Ringmaster Lockheart). -var public const config bool preventNPCDetonation; -// Certain spots prevent pipe bombs from having a line of sight to the -// nearby zeds, preventing them from ever exploding. This issue can be resolves -// by checking zed's proximity not to the pipe itself, but to a point directly -// above it (20 units above by default). -// If you wish to disable this part of the feature - set it to zero or -// negative value. Otherwise it is suggested to keep it at `20`. -var public const config float proximityCheckElevation; - -// Since this feature needs to do proximity check instead of pipes, we will -// need to track all the pipes we will be doing checks for. -// This struct contains all reference to the pipe itself and everything -// else we need to track. -struct PipeRecord -{ - // Pipe that this record tracks. - // Reference to `PipeBombProjectile`. - var NativeActorRef pipe; - // Each pipe has a separate timer for their scheduled proximity checks, - // so we need a separate variable to track when that time comes for - // each and every pipe - var float timerCountDown; - // `true` if we have already intercepted (and replaced with our own) - // the proximity check for the pipe in this record - var bool proximityCheckIntercepted; - // Reference to the `ExtendedZCollision` we created to catch - // `TakeDamage()` event. - // Reference to `PipesSafetyCollision`. - var NativeActorRef safetyCollision; -}; -var private array pipeRecords; - -// Store the `bAlwaysRelevant` of the `class'PipeBombProjectile'` before this -// feature activated to restore it in case it gets disabled -// (we need to change `bAlwaysRelevant` in order to catch events of -// pipe bombs spawning). -var private bool pipesRelevancyFlag; - -var private Timer cleanupTimer; +var public config bool preventMassiveDamage; +var public config bool preventSuspiciousDamage; +var public config bool preventCorpseDetonation; +var public config bool preventNPCDetonation; +var public config float proximityCheckElevation; -protected function OnEnabled() +protected function AssociativeArray ToData() { - local LevelInfo level; - local PipeBombProjectile nextPipe; - pipesRelevancyFlag = class'PipeBombProjectile'.default.bAlwaysRelevant; - class'PipeBombProjectile'.default.bGameRelevant = false; - // Set cleanup timer, there is little point to making - // clean up interval configurable. - cleanupTimer = _.time.StartTimer(5.0, true); - cleanupTimer.OnElapsed(self).connect = CleanPipeRecords; - // Fix pipes that are already lying about on the map - level = _.unreal.GetLevel(); - foreach level.DynamicActors(class'KFMod.PipeBombProjectile', nextPipe) { - RegisterPipe(nextPipe); - } -} - -protected function OnDisabled() -{ - local int i; - class'PipeBombProjectile'.default.bGameRelevant = pipesRelevancyFlag; - cleanupTimer.FreeSelf(); - for (i = 0; i < pipeRecords.length; i += 1) { - ReleasePipe(pipeRecords[i]); - } - pipeRecords.length = 0; + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(P("preventMassiveDamage"), preventMassiveDamage, true); + data.SetBool(P("preventSuspiciousDamage"), preventSuspiciousDamage, true); + data.SetBool(P("preventCorpseDetonation"), preventCorpseDetonation, true); + data.SetBool(P("preventNPCDetonation"), preventNPCDetonation, true); + data.SetFloat(P("proximityCheckElevation"), proximityCheckElevation, true); + return data; } -// Adds new pipe to our list and does necessary steps to replace logic of -// `TakeDamage()` and `Timer()` methods. -public final function RegisterPipe(PipeBombProjectile newPipe) +protected function FromData(AssociativeArray source) { - local int i; - local PipeRecord newRecord; - if (newPipe == none) { + if (source == none) { return; } - // Check whether we have already added this pipe - for (i = 0; i < pipeRecords.length; i += 1) - { - if (pipeRecords[i].pipe.Get() == newPipe) { - return; - } - } - newRecord.pipe = _.unreal.ActorRef(newPipe); - // Setup `PipesSafetyCollision` for catching `TakeDamage()` events - // (only if we need to according to settings) - if (NeedSafetyCollision()) - { - newRecord.safetyCollision = _.unreal.ActorRef( - class'PipesSafetyCollision'.static.ProtectPipes(newPipe)); - } - pipeRecords[pipeRecords.length] = newRecord; - // Intercept proximity checks (only if we need to according to settings) - if (NeedManagedProximityChecks()) - { - // We do this after we have added new pipe record to the complete list - // so that we can redo the check early for - // the previously recorded pipes - InterceptProximityChecks(); - } + preventMassiveDamage = source.GetBool(P("preventMassiveDamage")); + preventSuspiciousDamage = source.GetBool(P("preventSuspiciousDamage")); + preventCorpseDetonation = source.GetBool(P("preventCorpseDetonation")); + preventNPCDetonation = source.GetBool(P("preventNPCDetonation")); + proximityCheckElevation = source.GetFloat(P("proximityCheckElevation"), 20); } -// Rolls back our changes to the pipe in the given `PipeRecord`. -public final function ReleasePipe(PipeRecord pipeRecord) +protected function DefaultIt() { - local PipeBombProjectile pipe; - local PipesSafetyCollision safetyCollision; - if (pipeRecord.safetyCollision != none) - { - safetyCollision = - PipesSafetyCollision(pipeRecord.safetyCollision.Get()); - pipeRecord.safetyCollision.FreeSelf(); - pipeRecord.safetyCollision = none; - } - if (safetyCollision != none) { - safetyCollision.TurnOff(); - } - pipe = PipeBombProjectile(pipeRecord.pipe.Get()); - pipeRecord.pipe.FreeSelf(); - pipeRecord.pipe = none; - if (pipeRecord.proximityCheckIntercepted && pipe != none) - { - pipeRecord.proximityCheckIntercepted = false; - if (IsPipeDoingProximityChecks(pipe)) { - pipe.SetTimer(pipeRecord.timerCountDown, true); - } - } -} - -// Checks whether we actually need to use replace logic of `TakeDamage()` -// method according to settings. -private final function bool NeedSafetyCollision() -{ - if (preventMassiveDamage) return true; - if (preventSuspiciousDamage) return true; - return false; -} - -// Checks whether we actually need to use replace logic of `Timer()` -// method according to settings. -private final function bool NeedManagedProximityChecks() -{ - if (preventCorpseDetonation) return true; - if (preventNPCDetonation) return true; - if (proximityCheckElevation > 0.0) return true; - return false; -} - -// Removes dead records with pipe instances turned into `none` -private final function CleanPipeRecords(Timer source) -{ - local int i; - while (i < pipeRecords.length) - { - if (pipeRecords[i].pipe.Get() == none) - { - _.memory.Free(pipeRecords[i].pipe); - _.memory.Free(pipeRecords[i].safetyCollision); - pipeRecords.Remove(i, 1); - } - else { - i += 1; - } - } -} - -// Tries to replace logic of `SetTimer()` with our own for every pipe we -// keep track of that: -// 1. Started doing proximity checks; -// 2. Have not yet had it's logic replaced. -private final function InterceptProximityChecks() -{ - local int i; - local PipeBombProjectile nextPipe; - for (i = 0; i < pipeRecords.length; i += 1) - { - if (pipeRecords[i].proximityCheckIntercepted) continue; - nextPipe = PipeBombProjectile(pipeRecords[i].pipe.Get()); - if (nextPipe == none) continue; - - if (IsPipeDoingProximityChecks(nextPipe)) - { - // Turn off pipe's own timer - nextPipe.SetTimer(0, false); - // We set `1.0` because that is the vanilla value; - // Line 123 of "PipeBombProjectile.uc": `SetTimer(1.0,True);` - pipeRecords[i].timerCountDown = 1.0; - pipeRecords[i].proximityCheckIntercepted = true; - } - } -} - -// Assumes `pipe != none` -private final function bool IsPipeDoingProximityChecks(PipeBombProjectile pipe) -{ - // These checks need to pass so that `Timer()` call from - // "PipeBombProjectile.uc" enters the proximity check code - // (starting at line 131) - if (pipe.bHidden) return false; - if (pipe.bTriggered) return false; - if (pipe.bEnemyDetected) return false; - return (pipe.armingCountDown < 0); -} - -// Checks what pipes have their timers run out and doing proximity checks -// for them -private final function PerformProximityChecks(float delta) -{ - local int i; - local Vector checkLocation; - local PipeBombProjectile nextPipe; - for (i = 0; i < pipeRecords.length; i += 1) - { - pipeRecords[i].timerCountDown -= delta; - if (pipeRecords[i].timerCountDown > 0) continue; - nextPipe = PipeBombProjectile(pipeRecords[i].pipe.Get()); - if (nextPipe == none) continue; - // `timerCountDown` does not makes sense for pipes that - // are not doing proxiity checks - if (!IsPipeDoingProximityChecks(nextPipe)) continue; - - checkLocation = nextPipe.location; - if (proximityCheckElevation > 0) { - checkLocation.z += proximityCheckElevation; - } - // This method repeats vanilla logic with some additional checks - // and sets new timers by itself - DoPipeProximityCheck(pipeRecords[i], checkLocation); - } -} - -// Original code is somewhat messy and was reworked in this more manageble -// form as core logic is simple - for every nearby `Pawn` we increase the -// percieved level for the pipe and when it reaches certain threshold -// (`threatThreshhold = 1.0`) pipe explodes. -// In native logic there are three cases for increasing threat level: -// 1. Zeds increase it by a certain predefined -// (in `KFMonster` class amount); -// 2. Instigator (and his team, in case friendly fire is enabled and -// they can be hurt by pipes) raise threat level by a negligible -// amount (`0.001`) that will not lead to pipes exploding, but -// will cause them to beep faster, warning about the danger; -// 3. Some wacky code to check whether the `Pawn` is on the same team. -// In a regualar killing floor game only something that is neither -// player or zed can fit that. This is what causes corpses and -// KFO NPCs to explode pipes. -// Threat level is not directly set in vanilla code for -// this case, instead performing two operations -// `bEnemyDetected=true;` and `SetTimer(0.15,True);`. -// However, given the code that follows these calls, executing them -// is equivalent to adding `threatThreshhold` to the current -// threat level. Which is what we do here instead. -private final function DoPipeProximityCheck( - out PipeRecord pipeRecord, - Vector checkLocation) -{ - local Pawn checkPawn; - local float threatLevel; - local PipeBombProjectile pipe; - pipe = PipeBombProjectile(pipeRecord.pipe.Get()); - if (pipe == none) { - return; - } - pipe.bAlwaysRelevant = false; - pipe.PlaySound(pipe.beepSound,, 0.5,, 50.0); - // Out rewritten logic, which should do exactly the same: - foreach pipe.VisibleCollidingActors( class'Pawn', checkPawn, - pipe.detectionRadius, checkLocation) - { - threatLevel += GetThreatLevel(pipe, checkPawn); - // Explosion! No need to bother with the rest of the `Pawn`s. - if (threatLevel >= pipe.threatThreshhold) { - break; - } - } - // Resume vanilla code from "PipeBombProjectile.uc", lines from 169 to 181: - if(threatLevel >= pipe.threatThreshhold) - { - pipe.bEnemyDetected = true; - pipe.SetTimer(0.15, true); - } - else if(threatLevel > 0) { - pipeRecord.timerCountDown = 0.5; - } - else { - pipeRecord.timerCountDown = 1.0; - } -} - -// Threat level calculations are moves to a separate method to make algorithm -// less cumbersome -private final function float GetThreatLevel( - PipeBombProjectile pipe, - Pawn checkPawn) -{ - local bool onSameTeam; - local bool friendlyFireEnabled; - local KFGameType kfGame; - local PlayerReplicationInfo playerRI; - local KFMonster zed; - if (pipe == none) return 0.0; - if (checkPawn == none) return 0.0; - - playerRI = checkPawn.playerReplicationInfo; - if (pipe.level != none) { - kfGame = KFGameType(pipe.level.game); - } - if (kfGame != none) { - friendlyFireEnabled = kfGame.friendlyFireScale > 0; - } - // Warn teammates about pipes - onSameTeam = playerRI != none && playerRI.team.teamIndex == pipe.placedTeam; - if (checkPawn == pipe.instigator || (friendlyFireEnabled && onSameTeam)) { - return 0.001; - } - // Count zed's threat score - zed = KFMonster(checkPawn); - if (zed != none) { - return zed.motionDetectorThreat; - } - // Managed checks - if (preventCorpseDetonation && checkPawn.health <= 0) { - return 0.0; - } - if (preventNPCDetonation && KF_StoryNPC(CheckPawn) != none) { - return 0.0; - } - // Something weird demands a special full-alert treatment. - // Some checks are removed: - // 1. Removed `checkPawn.Role == ROLE_Authority` check, since we are - // working on server exclusively; - // 2. Removed `checkPawn != instigator` since, if - // `checkPawn == pipe.instigator`, previous `if` block will - // prevent us from reaching this point - if( playerRI != none && playerRI.team.teamIndex != pipe.placedTeam - || checkPawn.GetTeamNum() != pipe.placedTeam) - { - // Full threat score - return pipe.threatThreshhold; - } - return 0.0; -} - -event Tick(float delta) -{ - if (NeedManagedProximityChecks()) - { - InterceptProximityChecks(); - PerformProximityChecks(delta); - } + preventMassiveDamage = true; + preventSuspiciousDamage = true; + preventCorpseDetonation = true; + preventNPCDetonation = true; + proximityCheckElevation = 20.0; } defaultproperties { - preventMassiveDamage = true - preventSuspiciousDamage = true - preventCorpseDetonation = true - preventNPCDetonation = true - proximityCheckElevation = 20.0 - // Listeners - requiredListeners(0) = class'MutatorListener_FixPipes' + configName = "AcediaFixes" + preventMassiveDamage = true + preventSuspiciousDamage = true + preventCorpseDetonation = true + preventNPCDetonation = true + proximityCheckElevation = 20.0 } \ No newline at end of file diff --git a/sources/FixPipes/FixPipes_Feature.uc b/sources/FixPipes/FixPipes_Feature.uc new file mode 100644 index 0000000..2a3212a --- /dev/null +++ b/sources/FixPipes/FixPipes_Feature.uc @@ -0,0 +1,480 @@ +/** + * This feature addresses several bugs related to pipe bombs: + * 1. Gaining extra explosive damage by shooting pipes with + * a high fire rate weapon; + * 2. Other players exploding one's pipes; + * 3. Corpses and story NPCs exploding nearby pipes; + * 4. Pipes being stuck in places where they cannot detect nearby + * enemies and, therefore, not exploding. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixPipes_Feature extends Feature; + +/** + * There are two main culprits for the issues we are interested in: + * `TakeDamage()` and `Timer()` methods. `TakeDamage()` lacks sufficient checks + * for instigator allows for ways to damage it, causing explosion. It also + * lacks any checks for whether pipe bom already exploded, allowing players to + * damage it several time before it's destroyed, causing explosion every time. + * `Timer()` method simply contains a porximity check for hostile pawns that + * wrongfully detects certain actor (like `KF_StoryNPC` or players' corpses) + * and cannot detect zeds around itself if pipe is placed in a way that + * prevents it from having a direct line of sight towards zeds. + * To fix our issues we have to somehow overload both of these methods, + * which is impossible while keeping Acedia server-side. So we will have to + * do some hacks. + * + * `TakeDamage()`. To override this method's behavior we will remove actor + * collision from pipe bombs, preventing it from being directly damaged, then + * add an `ExtendedZCollision` subclass around it to catch `TakeDamage()` + * events that would normally target pipe bombs themselves. There we can add + * additional checks to help us decide whether pipe bombs should actually + * be damaged. + * + * `Timer()`. It would be simple to take control of this method by, say, + * spamming `SetTimer(0.0, false)` every tick and then executing our own logic + * elsewhere. However we only care about changin `Timer()`'s logic when pipe + * bomb attempts to detect cnearby enemies and do not want to manage the rest + * of `Timer()`'s logic. + * Pipe bombs work like this: after being thrown they fall until they land, + * invoking `Landed()` and `HitWall()` calls that start up the timer. Then + * timer uses `ArmingCountDown` variable to count down the time for pipe to + * "arm", that is, start functioning. It's after that that pipe starts to try + * and detect nearby enemies until it decides to start exploding sequence, + * instantly exploded with damage or disintegrated by siren's scream. + * We want to catch the moment when `ArmingCountDown` reaches zero and + * only then disable the timer. Then case we'll only need to reimplement logic + * that takes care of enemy detection. We also do not need to constantly + * re-reset the timer, since any code that will restart it would mean that + * pipe bomb no longer needs to look for enemies. + * When we detect enough enemies nearby or a change in pipe bomb's state + * due to outside factors (being exploded or disintegrated) we simply need to + * start it's the timer (if it was not done for us already). + */ + +// NOTE #1: setting either of `preventMassiveDamage` or +// `preventSuspiciousDamage` might change how and where pipes might fall on +// the ground (for example, pipe hats shoul not work anymore). In most cases, +// however, there should be no difference from vanilla gameplay. +// Setting this to `true` fixes a mechanic that allows pipes to deal extra +// damage when detonated with a high fire rate weapons: pipes will now always +// deal the same amount of damage. +var public /*config*/ bool preventMassiveDamage; +// It's possible for teammates to explode one's pipe under certain +// circumstances. Setting this setting to `true` will prevent that +// from happening. +var public /*config*/ bool preventSuspiciousDamage; +// Setting this to `true` will prevent pipe bombs from being detonated by +// the nearby corpses on other player. +var public /*config*/ bool preventCorpseDetonation; +// Setting this to `true` will prevents pipe bombs from being detonated by +// nearby KFO NPCs (Ringmaster Lockheart). +var public /*config*/ bool preventNPCDetonation; +// Certain spots prevent pipe bombs from having a line of sight to the +// nearby zeds, preventing them from ever exploding. This issue can be resolves +// by checking zed's proximity not to the pipe itself, but to a point directly +// above it (20 units above by default). +// If you wish to disable this part of the feature - set it to zero or +// negative value. Otherwise it is suggested to keep it at `20`. +var public /*config*/ float proximityCheckElevation; + +// Since this feature needs to do proximity check instead of pipes, we will +// need to track all the pipes we will be doing checks for. +// This struct contains all reference to the pipe itself and everything +// else we need to track. +struct PipeRecord +{ + // Pipe that this record tracks. + // Reference to `PipeBombProjectile`. + var NativeActorRef pipe; + // Each pipe has a separate timer for their scheduled proximity checks, + // so we need a separate variable to track when that time comes for + // each and every pipe + var float timerCountDown; + // `true` if we have already intercepted (and replaced with our own) + // the proximity check for the pipe in this record + var bool proximityCheckIntercepted; + // Reference to the `ExtendedZCollision` we created to catch + // `TakeDamage()` event. + // Reference to `PipesSafetyCollision`. + var NativeActorRef safetyCollision; +}; +var private array pipeRecords; + +// Store the `bAlwaysRelevant` of the `class'PipeBombProjectile'` before this +// feature activated to restore it in case it gets disabled +// (we need to change `bAlwaysRelevant` in order to catch events of +// pipe bombs spawning). +var private bool pipesRelevancyFlag; + +var private Timer cleanupTimer; + +protected function OnEnabled() +{ + local LevelInfo level; + local PipeBombProjectile nextPipe; + pipesRelevancyFlag = class'PipeBombProjectile'.default.bAlwaysRelevant; + class'PipeBombProjectile'.default.bGameRelevant = false; + _.unreal.mutator.OnCheckReplacement(self).connect = CheckReplacement; + // Set cleanup timer, there is little point to making + // clean up interval configurable. + cleanupTimer = _.time.StartTimer(5.0, true); + cleanupTimer.OnElapsed(self).connect = CleanPipeRecords; + // Fix pipes that are already lying about on the map + level = _.unreal.GetLevel(); + foreach level.DynamicActors(class'KFMod.PipeBombProjectile', nextPipe) { + RegisterPipe(nextPipe); + } +} + +protected function OnDisabled() +{ + local int i; + class'PipeBombProjectile'.default.bGameRelevant = pipesRelevancyFlag; + _.unreal.mutator.OnCheckReplacement(self).Disconnect(); + cleanupTimer.FreeSelf(); + for (i = 0; i < pipeRecords.length; i += 1) { + ReleasePipe(pipeRecords[i]); + } + pipeRecords.length = 0; +} + +protected function SwapConfig(FeatureConfig config) +{ + local int i; + local FixPipes newConfig; + newConfig = FixPipes(config); + if (newConfig == none) { + return; + } + preventMassiveDamage = newConfig.preventMassiveDamage; + preventSuspiciousDamage = newConfig.preventSuspiciousDamage; + preventCorpseDetonation = newConfig.preventCorpseDetonation; + preventNPCDetonation = newConfig.preventNPCDetonation; + proximityCheckElevation = newConfig.proximityCheckElevation; + for (i = 0; i < pipeRecords.length; i += 1) + { + ReleasePipe(pipeRecords[i]); + RegisterPipe(PipeBombProjectile(pipeRecords[i].safetyCollision.Get())); + } +} + +private function bool CheckReplacement(Actor other, out byte isSuperRelevant) +{ + local PipeBombProjectile pipeProjectile; + pipeProjectile = PipeBombProjectile(other); + if (pipeProjectile != none) { + RegisterPipe(PipeBombProjectile(other)); + } + return true; +} + +// Adds new pipe to our list and does necessary steps to replace logic of +// `TakeDamage()` and `Timer()` methods. +public final function RegisterPipe(PipeBombProjectile newPipe) +{ + local int i; + local PipeRecord newRecord; + if (newPipe == none) { + return; + } + // Check whether we have already added this pipe + for (i = 0; i < pipeRecords.length; i += 1) + { + if (pipeRecords[i].pipe.Get() == newPipe) { + return; + } + } + newRecord.pipe = _.unreal.ActorRef(newPipe); + // Setup `PipesSafetyCollision` for catching `TakeDamage()` events + // (only if we need to according to settings) + if (NeedSafetyCollision()) + { + newRecord.safetyCollision = _.unreal.ActorRef( + class'PipesSafetyCollision'.static.ProtectPipes(newPipe)); + } + pipeRecords[pipeRecords.length] = newRecord; + // Intercept proximity checks (only if we need to according to settings) + if (NeedManagedProximityChecks()) + { + // We do this after we have added new pipe record to the complete list + // so that we can redo the check early for + // the previously recorded pipes + InterceptProximityChecks(); + } +} + +// Rolls back our changes to the pipe in the given `PipeRecord`. +public final function ReleasePipe(PipeRecord pipeRecord) +{ + local PipeBombProjectile pipe; + local PipesSafetyCollision safetyCollision; + if (pipeRecord.safetyCollision != none) + { + safetyCollision = + PipesSafetyCollision(pipeRecord.safetyCollision.Get()); + pipeRecord.safetyCollision.FreeSelf(); + pipeRecord.safetyCollision = none; + } + if (safetyCollision != none) { + safetyCollision.TurnOff(); + } + pipe = PipeBombProjectile(pipeRecord.pipe.Get()); + pipeRecord.pipe.FreeSelf(); + pipeRecord.pipe = none; + if (pipeRecord.proximityCheckIntercepted && pipe != none) + { + pipeRecord.proximityCheckIntercepted = false; + if (IsPipeDoingProximityChecks(pipe)) { + pipe.SetTimer(pipeRecord.timerCountDown, true); + } + } +} + +// Checks whether we actually need to use replace logic of `TakeDamage()` +// method according to settings. +private final function bool NeedSafetyCollision() +{ + if (preventMassiveDamage) return true; + if (preventSuspiciousDamage) return true; + return false; +} + +// Checks whether we actually need to use replace logic of `Timer()` +// method according to settings. +private final function bool NeedManagedProximityChecks() +{ + if (preventCorpseDetonation) return true; + if (preventNPCDetonation) return true; + if (proximityCheckElevation > 0.0) return true; + return false; +} + +// Removes dead records with pipe instances turned into `none` +private final function CleanPipeRecords(Timer source) +{ + local int i; + while (i < pipeRecords.length) + { + if (pipeRecords[i].pipe.Get() == none) + { + _.memory.Free(pipeRecords[i].pipe); + _.memory.Free(pipeRecords[i].safetyCollision); + pipeRecords.Remove(i, 1); + } + else { + i += 1; + } + } +} + +// Tries to replace logic of `SetTimer()` with our own for every pipe we +// keep track of that: +// 1. Started doing proximity checks; +// 2. Have not yet had it's logic replaced. +private final function InterceptProximityChecks() +{ + local int i; + local PipeBombProjectile nextPipe; + for (i = 0; i < pipeRecords.length; i += 1) + { + if (pipeRecords[i].proximityCheckIntercepted) continue; + nextPipe = PipeBombProjectile(pipeRecords[i].pipe.Get()); + if (nextPipe == none) continue; + + if (IsPipeDoingProximityChecks(nextPipe)) + { + // Turn off pipe's own timer + nextPipe.SetTimer(0, false); + // We set `1.0` because that is the vanilla value; + // Line 123 of "PipeBombProjectile.uc": `SetTimer(1.0,True);` + pipeRecords[i].timerCountDown = 1.0; + pipeRecords[i].proximityCheckIntercepted = true; + } + } +} + +// Assumes `pipe != none` +private final function bool IsPipeDoingProximityChecks(PipeBombProjectile pipe) +{ + // These checks need to pass so that `Timer()` call from + // "PipeBombProjectile.uc" enters the proximity check code + // (starting at line 131) + if (pipe.bHidden) return false; + if (pipe.bTriggered) return false; + if (pipe.bEnemyDetected) return false; + return (pipe.armingCountDown < 0); +} + +// Checks what pipes have their timers run out and doing proximity checks +// for them +private final function PerformProximityChecks(float delta) +{ + local int i; + local Vector checkLocation; + local PipeBombProjectile nextPipe; + for (i = 0; i < pipeRecords.length; i += 1) + { + pipeRecords[i].timerCountDown -= delta; + if (pipeRecords[i].timerCountDown > 0) continue; + nextPipe = PipeBombProjectile(pipeRecords[i].pipe.Get()); + if (nextPipe == none) continue; + // `timerCountDown` does not makes sense for pipes that + // are not doing proxiity checks + if (!IsPipeDoingProximityChecks(nextPipe)) continue; + + checkLocation = nextPipe.location; + if (proximityCheckElevation > 0) { + checkLocation.z += proximityCheckElevation; + } + // This method repeats vanilla logic with some additional checks + // and sets new timers by itself + DoPipeProximityCheck(pipeRecords[i], checkLocation); + } +} + +// Original code is somewhat messy and was reworked in this more manageble +// form as core logic is simple - for every nearby `Pawn` we increase the +// percieved level for the pipe and when it reaches certain threshold +// (`threatThreshhold = 1.0`) pipe explodes. +// In native logic there are three cases for increasing threat level: +// 1. Zeds increase it by a certain predefined +// (in `KFMonster` class amount); +// 2. Instigator (and his team, in case friendly fire is enabled and +// they can be hurt by pipes) raise threat level by a negligible +// amount (`0.001`) that will not lead to pipes exploding, but +// will cause them to beep faster, warning about the danger; +// 3. Some wacky code to check whether the `Pawn` is on the same team. +// In a regualar killing floor game only something that is neither +// player or zed can fit that. This is what causes corpses and +// KFO NPCs to explode pipes. +// Threat level is not directly set in vanilla code for +// this case, instead performing two operations +// `bEnemyDetected=true;` and `SetTimer(0.15,True);`. +// However, given the code that follows these calls, executing them +// is equivalent to adding `threatThreshhold` to the current +// threat level. Which is what we do here instead. +private final function DoPipeProximityCheck( + out PipeRecord pipeRecord, + Vector checkLocation) +{ + local Pawn checkPawn; + local float threatLevel; + local PipeBombProjectile pipe; + pipe = PipeBombProjectile(pipeRecord.pipe.Get()); + if (pipe == none) { + return; + } + pipe.bAlwaysRelevant = false; + pipe.PlaySound(pipe.beepSound,, 0.5,, 50.0); + // Out rewritten logic, which should do exactly the same: + foreach pipe.VisibleCollidingActors( class'Pawn', checkPawn, + pipe.detectionRadius, checkLocation) + { + threatLevel += GetThreatLevel(pipe, checkPawn); + // Explosion! No need to bother with the rest of the `Pawn`s. + if (threatLevel >= pipe.threatThreshhold) { + break; + } + } + // Resume vanilla code from "PipeBombProjectile.uc", lines from 169 to 181: + if(threatLevel >= pipe.threatThreshhold) + { + pipe.bEnemyDetected = true; + pipe.SetTimer(0.15, true); + } + else if(threatLevel > 0) { + pipeRecord.timerCountDown = 0.5; + } + else { + pipeRecord.timerCountDown = 1.0; + } +} + +// Threat level calculations are moves to a separate method to make algorithm +// less cumbersome +private final function float GetThreatLevel( + PipeBombProjectile pipe, + Pawn checkPawn) +{ + local bool onSameTeam; + local bool friendlyFireEnabled; + local KFGameType kfGame; + local PlayerReplicationInfo playerRI; + local KFMonster zed; + if (pipe == none) return 0.0; + if (checkPawn == none) return 0.0; + + playerRI = checkPawn.playerReplicationInfo; + if (pipe.level != none) { + kfGame = KFGameType(pipe.level.game); + } + if (kfGame != none) { + friendlyFireEnabled = kfGame.friendlyFireScale > 0; + } + // Warn teammates about pipes + onSameTeam = playerRI != none && playerRI.team.teamIndex == pipe.placedTeam; + if (checkPawn == pipe.instigator || (friendlyFireEnabled && onSameTeam)) { + return 0.001; + } + // Count zed's threat score + zed = KFMonster(checkPawn); + if (zed != none) { + return zed.motionDetectorThreat; + } + // Managed checks + if (preventCorpseDetonation && checkPawn.health <= 0) { + return 0.0; + } + if (preventNPCDetonation && KF_StoryNPC(CheckPawn) != none) { + return 0.0; + } + // Something weird demands a special full-alert treatment. + // Some checks are removed: + // 1. Removed `checkPawn.Role == ROLE_Authority` check, since we are + // working on server exclusively; + // 2. Removed `checkPawn != instigator` since, if + // `checkPawn == pipe.instigator`, previous `if` block will + // prevent us from reaching this point + if( playerRI != none && playerRI.team.teamIndex != pipe.placedTeam + || checkPawn.GetTeamNum() != pipe.placedTeam) + { + // Full threat score + return pipe.threatThreshhold; + } + return 0.0; +} + +event Tick(float delta) +{ + if (NeedManagedProximityChecks()) + { + InterceptProximityChecks(); + PerformProximityChecks(delta); + } +} + +defaultproperties +{ + configClass = class'FixPipes' + preventMassiveDamage = true + preventSuspiciousDamage = true + preventCorpseDetonation = true + preventNPCDetonation = true + proximityCheckElevation = 20.0 +} \ No newline at end of file diff --git a/sources/FixPipes/MutatorListener_FixPipes.uc b/sources/FixPipes/MutatorListener_FixPipes.uc deleted file mode 100644 index 92a880a..0000000 --- a/sources/FixPipes/MutatorListener_FixPipes.uc +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Overloaded mutator events listener to register spawned pipe bombs. - * Copyright 2021 Anton Tarasenko - *------------------------------------------------------------------------------ - * This file is part of Acedia. - * - * Acedia is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Acedia is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Acedia. If not, see . - */ -class MutatorListener_FixPipes extends MutatorListenerBase - abstract; - -static function bool CheckReplacement(Actor other, out byte isSuperRelevant) -{ - local FixPipes pipesFix; - local PipeBombProjectile pipeProjectile; - pipeProjectile = PipeBombProjectile(other); - if (pipeProjectile == none) return true; - pipesFix = FixPipes(class'FixPipes'.static.GetInstance()); - if (pipesFix == none) return true; - - pipesFix.RegisterPipe(pipeProjectile); - return true; -} - -defaultproperties -{ - relatedEvents = class'MutatorEvents' -} \ No newline at end of file diff --git a/sources/FixPipes/PipesSafetyCollision.uc b/sources/FixPipes/PipesSafetyCollision.uc index 73d6442..7f7f0d2 100644 --- a/sources/FixPipes/PipesSafetyCollision.uc +++ b/sources/FixPipes/PipesSafetyCollision.uc @@ -47,8 +47,9 @@ public final static function PipesSafetyCollision ProtectPipes( private function bool IsSuspicious(Pawn instigator) { // Instigator vanished - if (instigator == none) return true; - + if (instigator == none) { + return true; + } // Instigator already became spectator if (KFPawn(instigator) != none) { @@ -80,11 +81,11 @@ function TakeDamage( class damageType, optional int hitIndex) { - local FixPipes pipesFix; + local FixPipes_Feature pipesFix; local PipeBombProjectile target; target = PipeBombProjectile(owner); if (target == none) return; - pipesFix = FixPipes(class'FixPipes'.static.GetInstance()); + pipesFix = FixPipes_Feature(class'FixPipes_Feature'.static.GetInstance()); if (pipesFix == none) return; if (pipesFix.preventMassiveDamage && target.bTriggered) return; if (pipesFix.preventSuspiciousDamage && IsSuspicious(instigator)) return; diff --git a/sources/FixProjectileFF/FixProjectileFF.uc b/sources/FixProjectileFF/FixProjectileFF.uc index 3a2c277..b66f78e 100644 --- a/sources/FixProjectileFF/FixProjectileFF.uc +++ b/sources/FixProjectileFF/FixProjectileFF.uc @@ -1,12 +1,5 @@ /** - * This feature addresses the bug that allows teammates to explode some of - * the player's projectiles by damaging them even when friendly fire is - * turned off, therefore killing the player (whether by accident or not). - * - * Problem is solved by "disarming" projectiles vulnerable to this - * friendly fire and replacing them with our own class of projectile that is - * spawned only on a server and does additional safety checks to ensure it will - * only explode when it is expected from it. + * Config object for `FixProjectileFF_Feature`. * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. @@ -24,138 +17,34 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixProjectileFF extends Feature +class FixProjectileFF extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * All projectiles vulnerable to this bug (we consider only those that can - * explode and harm the player) are derived from `ROBallisticProjectile`. When - * one of them is spawned we will: - * 1. Set it's damage parameters to zero, to ensure that it won't damage - * anyone or anything if other measures fail; - * 2. Disable it's collision, preventing it from being accessed via - * `VisibleCollidingActors()` method that is usually used to damage - * these projectiles. - * Then we spawn our own version of the projectile with fixed - * `TakeDamage()` that does additional check that either it's projectile's - * owner that damages it or friendly fire is enabled. - * To do this replacement we will need to catch the moment when vanilla - * projectile spawns. Unfortunately, this cannot be done by default, since all - * projectiles have `bGameRelevant` flag set to `true`, which prevents - * `CheckReplacement()` from being called for them. So, when feature is - * enabled, it forces all the projectiles we are interested in to have - * `bGameRelevant = false`. - * - * Issue arises from the fact that these two projectiles can desynchronize: - * old projectile, that client sees, might not be in the same location as the - * new one (especially since we are disabling collisions for the old one), that - * deals damage. There are two cases to consider, depending on - * the `bNetTemporary` of the old projectile: - * 1. `bNetTemporary == true`: projectile version on client side is - * independent from the server-side's one. In this case there is - * nothing we can do. In fact, vanilla game suffers from this very bug - * that makes, say, M79 projectile fly past the zed it exploded - * (usually after killing it). To deal with this we would need - * the ability to affect client's code, which cannot be done in - * the server mode. - * 2. `bNetTemporary == true`: projectile version on client side is - * actually synchronized with the server-side's one. In this case we - * will simply make new projectile to constantly force the old one to - * be in the same place at the same rotation. We will also propagate - * various state-changing events such as exploding, disintegrating from - * siren's scream or sticking to the wall/zed. That is, we will make - * the old projectile (that client can see) "the face" of the new one - * (that client cannot see). Only "The Orca Bomb Propeller" and - * "SealSqueal Harpoon Bomber" fall into this group. - */ - -// By default (`ignoreFriendlyFire == false`), when friendly fire is enabled -// (at any level), this fix allows other players to explode one's projectiles. -// By setting this to `true` you will force this fix to prevent it no matter -// what, even when server has full friendly fire enabled. -var private config bool ignoreFriendlyFire; - -// Stores what pairs of projectile classes that describe what (vulnerable) -// class must be replaced with what (protected class). It also remembers the -// previous state of `bGameRelevant` for replaceable class to restore it in -// case this feature is disabled. -struct ReplacementRule -{ - // `bGameRelevant` before this feature set it to `false` - var bool relevancyFlag; - var class vulnerableClass; - var class protectedClass; -}; -var private const array rules; - -protected function OnEnabled() -{ - local int i; - for (i = 0; i < rules.length; i += 1) - { - if (rules[i].vulnerableClass == none) continue; - if (rules[i].protectedClass == none) continue; - rules[i].relevancyFlag = rules[i].vulnerableClass.default.bGameRelevant; - rules[i].vulnerableClass.default.bGameRelevant = false; - } -} +var public config bool ignoreFriendlyFire; -protected function OnDisabled() +protected function AssociativeArray ToData() { - local int i; - for (i = 0; i < rules.length; i += 1) - { - if (rules[i].vulnerableClass == none) continue; - if (rules[i].protectedClass == none) continue; - rules[i].vulnerableClass.default.bGameRelevant = rules[i].relevancyFlag; - } + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(P("ignoreFriendlyFire"), ignoreFriendlyFire, true); + return data; } -// Returns "fixed" class that no longer explodes from random damage -public final static function class FindFixedClass( - class projectileClass) +protected function FromData(AssociativeArray source) { - local int i; - local array rulesCopy; - if (projectileClass == none) return none; - - rulesCopy = default.rules; - for (i = 0; i < rulesCopy.length; i += 1) - { - if (rulesCopy[i].vulnerableClass == projectileClass) { - return rulesCopy[i].protectedClass; - } + if (source != none) { + ignoreFriendlyFire = source.GetBool(P("ignoreFriendlyFire")); } - return projectileClass; } -// Check if, according to this fix, projectiles should explode from -// friendly fire -// If `FixProjectileFF` in disabled always returns `false`. -public final static function bool IsFriendlyFireAcceptable() +protected function DefaultIt() { - local FixProjectileFF projectileFFFix; - projectileFFFix = FixProjectileFF(GetInstance()); - if (projectileFFFix == none) return false; - if (projectileFFFix.ignoreFriendlyFire) return false; - - return __().unreal.GetKFGameType().friendlyFireScale > 0; + ignoreFriendlyFire = false; } defaultproperties { + configName = "AcediaFixes" ignoreFriendlyFire = false - rules(0) = (vulnerableClass=class'KFMod.M79GrenadeProjectile',protectedClass=class'FixProjectileFFClass_M79GrenadeProjectile') - rules(1) = (vulnerableClass=class'KFMod.M32GrenadeProjectile',protectedClass=class'FixProjectileFFClass_M32GrenadeProjectile') - rules(2) = (vulnerableClass=class'KFMod.LAWProj',protectedClass=class'FixProjectileFFClass_LAWProj') - rules(3) = (vulnerableClass=class'KFMod.M203GrenadeProjectile',protectedClass=class'FixProjectileFFClass_M203GrenadeProjectile') - rules(4) = (vulnerableClass=class'KFMod.ZEDGunProjectile',protectedClass=class'FixProjectileFFClass_ZEDGunProjectile') - rules(5) = (vulnerableClass=class'KFMod.ZEDMKIIPrimaryProjectile',protectedClass=class'FixProjectileFFClass_ZEDMKIIPrimaryProjectile') - rules(6) = (vulnerableClass=class'KFMod.ZEDMKIISecondaryProjectile',protectedClass=class'FixProjectileFFClass_ZEDMKIISecondaryProjectile') - rules(7) = (vulnerableClass=class'KFMod.FlareRevolverProjectile',protectedClass=class'FixProjectileFFClass_FlareRevolverProjectile') - rules(8) = (vulnerableClass=class'KFMod.HuskGunProjectile',protectedClass=class'FixProjectileFFClass_HuskGunProjectile') - rules(9) = (vulnerableClass=class'KFMod.SPGrenadeProjectile',protectedClass=class'FixProjectileFFClass_SPGrenadeProjectile') - rules(10) = (vulnerableClass=class'KFMod.SealSquealProjectile',protectedClass=class'FixProjectileFFClass_SealSquealProjectile') - // Listeners - requiredListeners(0) = class'MutatorListener_FixProjectileFF' } \ No newline at end of file diff --git a/sources/FixProjectileFF/FixProjectileFF_Feature.uc b/sources/FixProjectileFF/FixProjectileFF_Feature.uc new file mode 100644 index 0000000..b9bf80f --- /dev/null +++ b/sources/FixProjectileFF/FixProjectileFF_Feature.uc @@ -0,0 +1,312 @@ +/** + * This feature addresses the bug that allows teammates to explode some of + * the player's projectiles by damaging them even when friendly fire is + * turned off, therefore killing the player (whether by accident or not). + * + * Problem is solved by "disarming" projectiles vulnerable to this + * friendly fire and replacing them with our own class of projectile that is + * spawned only on a server and does additional safety checks to ensure it will + * only explode when it is expected from it. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixProjectileFF_Feature extends Feature; + +/** + * All projectiles vulnerable to this bug (we consider only those that can + * explode and harm the player) are derived from `ROBallisticProjectile`. When + * one of them is spawned we will: + * 1. Set it's damage parameters to zero, to ensure that it won't damage + * anyone or anything if other measures fail; + * 2. Disable it's collision, preventing it from being accessed via + * `VisibleCollidingActors()` method that is usually used to damage + * these projectiles. + * Then we spawn our own version of the projectile with fixed + * `TakeDamage()` that does additional check that either it's projectile's + * owner that damages it or friendly fire is enabled. + * To do this replacement we will need to catch the moment when vanilla + * projectile spawns. Unfortunately, this cannot be done by default, since all + * projectiles have `bGameRelevant` flag set to `true`, which prevents + * `CheckReplacement()` from being called for them. So, when feature is + * enabled, it forces all the projectiles we are interested in to have + * `bGameRelevant = false`. + * + * Issue arises from the fact that these two projectiles can desynchronize: + * old projectile, that client sees, might not be in the same location as the + * new one (especially since we are disabling collisions for the old one), that + * deals damage. There are two cases to consider, depending on + * the `bNetTemporary` of the old projectile: + * 1. `bNetTemporary == true`: projectile version on client side is + * independent from the server-side's one. In this case there is + * nothing we can do. In fact, vanilla game suffers from this very bug + * that makes, say, M79 projectile fly past the zed it exploded + * (usually after killing it). To deal with this we would need + * the ability to affect client's code, which cannot be done in + * the server mode. + * 2. `bNetTemporary == false`: projectile version on client side is + * actually synchronized with the server-side's one. In this case we + * will simply make new projectile to constantly force the old one to + * be in the same place at the same rotation. We will also propagate + * various state-changing events such as exploding, disintegrating from + * siren's scream or sticking to the wall/zed. That is, we will make + * the old projectile (that client can see) "the face" of the new one + * (that client cannot see). Only "The Orca Bomb Propeller" and + * "SealSqueal Harpoon Bomber" fall into this group. + */ + +// By default (`ignoreFriendlyFire == false`), when friendly fire is enabled +// (at any level), this fix allows other players to explode one's projectiles. +// By setting this to `true` you will force this fix to prevent it no matter +// what, even when server has full friendly fire enabled. +var private /*config*/ bool ignoreFriendlyFire; + +// Stores what pairs of projectile classes that describe what (vulnerable) +// class must be replaced with what (protected class). It also remembers the +// previous state of `bGameRelevant` for replaceable class to restore it in +// case this feature is disabled. +struct ReplacementRule +{ + // `bGameRelevant` before this feature set it to `false` + var bool relevancyFlag; + var class vulnerableClass; + var class protectedClass; +}; +var private const array rules; + +protected function OnEnabled() +{ + local int i; + for (i = 0; i < rules.length; i += 1) + { + if (rules[i].vulnerableClass == none) continue; + if (rules[i].protectedClass == none) continue; + rules[i].relevancyFlag = rules[i].vulnerableClass.default.bGameRelevant; + rules[i].vulnerableClass.default.bGameRelevant = false; + } + _.unreal.mutator.OnCheckReplacement(self).connect = CheckReplacement; +} + +protected function OnDisabled() +{ + local int i; + for (i = 0; i < rules.length; i += 1) + { + if (rules[i].vulnerableClass == none) continue; + if (rules[i].protectedClass == none) continue; + rules[i].vulnerableClass.default.bGameRelevant = rules[i].relevancyFlag; + } + _.unreal.mutator.OnCheckReplacement(self).Disconnect(); +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixProjectileFF newConfig; + newConfig = FixProjectileFF(config); + if (newConfig == none) { + return; + } + ignoreFriendlyFire = newConfig.ignoreFriendlyFire; + default.ignoreFriendlyFire = ignoreFriendlyFire; +} + +private function bool CheckReplacement(Actor other, out byte isSuperRelevant) +{ + local ROBallisticProjectile projectile; + local class newClass; + projectile = ROBallisticProjectile(other); + if (projectile == none) { + return true; + } + projectile.bGameRelevant = true; + newClass = FindFixedClass(projectile.class); + if (newClass == none || newClass == projectile.class) { + return true; + } + ReplaceProjectileWith(ROBallisticProjectile(other), newClass); + return true; +} + +private function ReplaceProjectileWith( + ROBallisticProjectile oldProjectile, + class replacementClass) +{ + local Pawn instigator; + local ROBallisticProjectile newProjectile; + if (oldProjectile == none) return; + if (replacementClass == none) return; + instigator = oldProjectile.instigator; + if (instigator == none) return; + newProjectile = instigator.Spawn( replacementClass,,, + oldProjectile.location, + oldProjectile.rotation); + if (newProjectile == none) return; + + // Move projectile damage data to make sure new projectile deals + // exactly the same damage as the old one and the old one no longer + // deals any. + // Technically we only need to zero `oldProjectile` damage values for + // most weapons, since they are automatically the same for the + // new projectile. + // However `KFMod.HuskGun` is an exception that changes these values + // depending of the charge, so we need to either consider this as a + // special case or just move values all the time - which is what we have + // chosen to do. + newProjectile.damage = oldProjectile.damage; + oldProjectile.damage = 0; + newProjectile.damageRadius = oldProjectile.damageRadius; + oldProjectile.damageRadius = 0; + MoveImpactDamage(oldProjectile, newProjectile); + // New projectile must govern all of the mechanics, so make the old mimic + // former's movements as close as possible + SetupOldProjectileAsFace(oldProjectile, newProjectile); +} + +// Make old projectile behave as close to the new one as possible +private function SetupOldProjectileAsFace( + ROBallisticProjectile oldProjectile, + ROBallisticProjectile newProjectile) +{ + local FixProjectileFFClass_SPGrenadeProjectile newProjectileAsOrca; + local FixProjectileFFClass_SealSquealProjectile newProjectileAsHarpoon; + if (oldProjectile == none) return; + if (newProjectile == none) return; + + // Removing collisions: + // 1. Avoids unnecessary bumping into zeds and other `Actor`s; + // 2. Removes `oldProjectile` from being accessible by + // `VisibleCollidingActors()`, therefore avoiding unwanted + // `TakeDamage()` calls from friendly fire. + oldProjectile.SetCollision(false, false); + // Prevent `oldProjectile` from dealing explosion damage in case something + // still damages and explodes it + oldProjectile.bHurtEntry = true; + // We can only make client-side projectile follow new server-side one for + // two projectile classes + newProjectileAsOrca = + FixProjectileFFClass_SPGrenadeProjectile(newProjectile); + if (newProjectileAsOrca != none) + { + newProjectileAsOrca.SetFace(SPGrenadeProjectile(oldProjectile)); + return; + } + newProjectileAsHarpoon = + FixProjectileFFClass_SealSquealProjectile(newProjectile); + if (newProjectileAsHarpoon != none) { + newProjectileAsHarpoon.SetFace(SealSquealProjectile(oldProjectile)); + } +} + +// `impactDamage` is separately defined in 4 different base classes of interest +// and moving (or zeroing) it is cumbersome enough to warrant a separate method +private function MoveImpactDamage( + ROBallisticProjectile oldProjectile, + ROBallisticProjectile newProjectile) +{ + local LAWProj oldProjectileAsLaw, newProjectileAsLaw; + local M79GrenadeProjectile oldProjectileAsM79, newProjectileAsM79; + local SPGrenadeProjectile oldProjectileAsOrca, newProjectileAsOrca; + local SealSquealProjectile oldProjectileAsHarpoon, newProjectileAsHarpoon; + if (oldProjectile == none) return; + if (newProjectile == none) return; + + // L.A.W + derivatives: + // Zed guns, Flare Revolver, Husk Gun + oldProjectileAsLaw = LawProj(oldProjectile); + newProjectileAsLaw = LawProj(newProjectile); + if (oldProjectileAsLaw != none && newProjectileAsLaw != none) + { + newProjectileAsLaw.impactDamage = oldProjectileAsLaw.impactDamage; + oldProjectileAsLaw.impactDamage = 0; + return; + } + // M79 Grenade Launcher + derivatives: + // M32, M4 203 + oldProjectileAsM79 = M79GrenadeProjectile(oldProjectile); + newProjectileAsM79 = M79GrenadeProjectile(newProjectile); + if (oldProjectileAsM79 != none && newProjectileAsM79 != none) + { + newProjectileAsM79.impactDamage = oldProjectileAsM79.impactDamage; + oldProjectileAsM79.impactDamage = 0; + return; + } + // The Orca Bomb Propeller + oldProjectileAsOrca = SPGrenadeProjectile(oldProjectile); + newProjectileAsOrca = SPGrenadeProjectile(newProjectile); + if (oldProjectileAsOrca != none && newProjectileAsOrca != none) + { + newProjectileAsOrca.impactDamage = oldProjectileAsOrca.impactDamage; + oldProjectileAsOrca.impactDamage = 0; + return; + } + // SealSqueal Harpoon Bomber + oldProjectileAsHarpoon = SealSquealProjectile(oldProjectile); + newProjectileAsHarpoon = SealSquealProjectile(newProjectile); + if (oldProjectileAsHarpoon != none && newProjectileAsHarpoon != none) + { + newProjectileAsHarpoon.impactDamage = + oldProjectileAsHarpoon.impactDamage; + oldProjectileAsHarpoon.impactDamage = 0; + return; + } +} + +// Returns "fixed" class that no longer explodes from random damage +private final function class FindFixedClass( + class projectileClass) +{ + local int i; + local array rulesCopy; + if (projectileClass == none) { + return none; + } + rulesCopy = default.rules; + for (i = 0; i < rulesCopy.length; i += 1) + { + if (rulesCopy[i].vulnerableClass == projectileClass) { + return rulesCopy[i].protectedClass; + } + } + return projectileClass; +} + +// Check if, according to this fix, projectiles should explode from +// friendly fire +// If `FixProjectileFF` in disabled always returns `false`. +public static final function bool IsFriendlyFireAcceptable() +{ + if (default.ignoreFriendlyFire) { + return false; + } + return __().unreal.GetKFGameType().friendlyFireScale > 0; +} + +defaultproperties +{ + configClass = class'FixProjectileFF' + ignoreFriendlyFire = false + rules(0) = (vulnerableClass=class'KFMod.M79GrenadeProjectile',protectedClass=class'FixProjectileFFClass_M79GrenadeProjectile') + rules(1) = (vulnerableClass=class'KFMod.M32GrenadeProjectile',protectedClass=class'FixProjectileFFClass_M32GrenadeProjectile') + rules(2) = (vulnerableClass=class'KFMod.LAWProj',protectedClass=class'FixProjectileFFClass_LAWProj') + rules(3) = (vulnerableClass=class'KFMod.M203GrenadeProjectile',protectedClass=class'FixProjectileFFClass_M203GrenadeProjectile') + rules(4) = (vulnerableClass=class'KFMod.ZEDGunProjectile',protectedClass=class'FixProjectileFFClass_ZEDGunProjectile') + rules(5) = (vulnerableClass=class'KFMod.ZEDMKIIPrimaryProjectile',protectedClass=class'FixProjectileFFClass_ZEDMKIIPrimaryProjectile') + rules(6) = (vulnerableClass=class'KFMod.ZEDMKIISecondaryProjectile',protectedClass=class'FixProjectileFFClass_ZEDMKIISecondaryProjectile') + rules(7) = (vulnerableClass=class'KFMod.FlareRevolverProjectile',protectedClass=class'FixProjectileFFClass_FlareRevolverProjectile') + rules(8) = (vulnerableClass=class'KFMod.HuskGunProjectile',protectedClass=class'FixProjectileFFClass_HuskGunProjectile') + rules(9) = (vulnerableClass=class'KFMod.SPGrenadeProjectile',protectedClass=class'FixProjectileFFClass_SPGrenadeProjectile') + rules(10) = (vulnerableClass=class'KFMod.SealSquealProjectile',protectedClass=class'FixProjectileFFClass_SealSquealProjectile') +} \ No newline at end of file diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_FlareRevolverProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_FlareRevolverProjectile.uc index 46b8831..86f7d31 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_FlareRevolverProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_FlareRevolverProjectile.uc @@ -38,7 +38,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_HuskGunProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_HuskGunProjectile.uc index f79d77d..e9809a1 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_HuskGunProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_HuskGunProjectile.uc @@ -37,7 +37,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_LAWProj.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_LAWProj.uc index d823df2..3155373 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_LAWProj.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_LAWProj.uc @@ -37,7 +37,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M203GrenadeProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M203GrenadeProjectile.uc index 5664c19..d99a9df 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M203GrenadeProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M203GrenadeProjectile.uc @@ -37,7 +37,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M32GrenadeProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M32GrenadeProjectile.uc index 8c5179c..f835b0d 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M32GrenadeProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M32GrenadeProjectile.uc @@ -37,7 +37,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M79GrenadeProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M79GrenadeProjectile.uc index 9eaa34b..879261b 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M79GrenadeProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_M79GrenadeProjectile.uc @@ -37,7 +37,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_SPGrenadeProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_SPGrenadeProjectile.uc index f4b575c..4ca72d8 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_SPGrenadeProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_SPGrenadeProjectile.uc @@ -44,7 +44,7 @@ public function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); // Unlike M79/M32 - no `!bDud` check, since it's supposed to fall down if (canTakeThisDamage) { diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_SealSquealProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_SealSquealProjectile.uc index 0799922..4595c52 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_SealSquealProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_SealSquealProjectile.uc @@ -44,7 +44,7 @@ public function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); // Unlike M79/M32 - no `!bDud` check, since it's supposed to fall down if (canTakeThisDamage) { diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDGunProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDGunProjectile.uc index 2beb6bc..296d631 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDGunProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDGunProjectile.uc @@ -37,7 +37,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDMKIIPrimaryProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDMKIIPrimaryProjectile.uc index a9ec190..d3d7cf1 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDMKIIPrimaryProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDMKIIPrimaryProjectile.uc @@ -38,7 +38,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDMKIISecondaryProjectile.uc b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDMKIISecondaryProjectile.uc index 673806e..fa907a8 100644 --- a/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDMKIISecondaryProjectile.uc +++ b/sources/FixProjectileFF/FixedClasses/FixProjectileFFClass_ZEDMKIISecondaryProjectile.uc @@ -37,7 +37,7 @@ function TakeDamage( } canTakeThisDamage = (instigatedBy == instigator) - || class'FixProjectileFF'.static.IsFriendlyFireAcceptable(); + || class'FixProjectileFF_Feature'.static.IsFriendlyFireAcceptable(); if (canTakeThisDamage && !bDud) { Explode(hitLocation, Vect(0, 0, 0)); } diff --git a/sources/FixProjectileFF/MutatorListener_FixProjectileFF.uc b/sources/FixProjectileFF/MutatorListener_FixProjectileFF.uc deleted file mode 100644 index 95ba8d0..0000000 --- a/sources/FixProjectileFF/MutatorListener_FixProjectileFF.uc +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Overloaded mutator events listener to replace projectiles vulnerable to - * friendly fire. - * Copyright 2021 Anton Tarasenko - *------------------------------------------------------------------------------ - * This file is part of Acedia. - * - * Acedia is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Acedia is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Acedia. If not, see . - */ -class MutatorListener_FixProjectileFF extends MutatorListenerBase - abstract; - -static function bool CheckReplacement(Actor other, out byte isSuperRelevant) -{ - local ROBallisticProjectile projectile; - local class newClass; - projectile = ROBallisticProjectile(other); - if (projectile == none) return true; - - projectile.bGameRelevant = true; - newClass = - class'FixProjectileFF'.static.FindFixedClass(projectile.class); - if (newClass == none || newClass == projectile.class) { - return true; - } - ReplaceProjectileWith(ROBallisticProjectile(other), newClass); - return true; -} - -static function ReplaceProjectileWith( - ROBallisticProjectile oldProjectile, - class replacementClass) -{ - local Pawn instigator; - local ROBallisticProjectile newProjectile; - if (oldProjectile == none) return; - if (replacementClass == none) return; - instigator = oldProjectile.instigator; - if (instigator == none) return; - newProjectile = instigator.Spawn( replacementClass,,, - oldProjectile.location, - oldProjectile.rotation); - if (newProjectile == none) return; - - // Move projectile damage data to make sure new projectile deals - // exactly the same damage as the old one and the old one no longer - // deals any. - // Technically we only need to zero `oldProjectile` damage values for - // most weapons, since they are automatically the same for the - // new projectile. - // However `KFMod.HuskGun` is an exception that changes these values - // depending of the charge, so we need to either consider this as a - // special case or just move values all the time - which is what we have - // chosen to do. - newProjectile.damage = oldProjectile.damage; - oldProjectile.damage = 0; - newProjectile.damageRadius = oldProjectile.damageRadius; - oldProjectile.damageRadius = 0; - MoveImpactDamage(oldProjectile, newProjectile); - // New projectile must govern all of the mechanics, so make the old mimic - // former's movements as close as possible - SetupOldProjectileAsFace(oldProjectile, newProjectile); -} - -// Make old projectile behave as close to the new one as possible -static function SetupOldProjectileAsFace( - ROBallisticProjectile oldProjectile, - ROBallisticProjectile newProjectile) -{ - local FixProjectileFFClass_SPGrenadeProjectile newProjectileAsOrca; - local FixProjectileFFClass_SealSquealProjectile newProjectileAsHarpoon; - if (oldProjectile == none) return; - if (newProjectile == none) return; - - // Removing collisions: - // 1. Avoids unnecessary bumping into zeds and other `Actor`s; - // 2. Removes `oldProjectile` from being accessible by - // `VisibleCollidingActors()`, therefore avoiding unwanted - // `TakeDamage()` calls from friendly fire. - oldProjectile.SetCollision(false, false); - // Prevent `oldProjectile` from dealing explosion damage in case something - // still damages and explodes it - oldProjectile.bHurtEntry = true; - // We can only make client-side projectile follow new server-side one for - // two projectile classes - newProjectileAsOrca = - FixProjectileFFClass_SPGrenadeProjectile(newProjectile); - if (newProjectileAsOrca != none) - { - newProjectileAsOrca.SetFace(SPGrenadeProjectile(oldProjectile)); - return; - } - newProjectileAsHarpoon = - FixProjectileFFClass_SealSquealProjectile(newProjectile); - if (newProjectileAsHarpoon != none) { - newProjectileAsHarpoon.SetFace(SealSquealProjectile(oldProjectile)); - } -} - -// `impactDamage` is separately defined in 4 different base classes of interest -// and moving (or zeroing) it is cumbersome enough to warrant a separate method -static function MoveImpactDamage( - ROBallisticProjectile oldProjectile, - ROBallisticProjectile newProjectile) -{ - local LAWProj oldProjectileAsLaw, newProjectileAsLaw; - local M79GrenadeProjectile oldProjectileAsM79, newProjectileAsM79; - local SPGrenadeProjectile oldProjectileAsOrca, newProjectileAsOrca; - local SealSquealProjectile oldProjectileAsHarpoon, newProjectileAsHarpoon; - if (oldProjectile == none) return; - if (newProjectile == none) return; - - // L.A.W + derivatives: - // Zed guns, Flare Revolver, Husk Gun - oldProjectileAsLaw = LawProj(oldProjectile); - newProjectileAsLaw = LawProj(newProjectile); - if (oldProjectileAsLaw != none && newProjectileAsLaw != none) - { - newProjectileAsLaw.impactDamage = oldProjectileAsLaw.impactDamage; - oldProjectileAsLaw.impactDamage = 0; - return; - } - // M79 Grenade Launcher + derivatives: - // M32, M4 203 - oldProjectileAsM79 = M79GrenadeProjectile(oldProjectile); - newProjectileAsM79 = M79GrenadeProjectile(newProjectile); - if (oldProjectileAsM79 != none && newProjectileAsM79 != none) - { - newProjectileAsM79.impactDamage = oldProjectileAsM79.impactDamage; - oldProjectileAsM79.impactDamage = 0; - return; - } - // The Orca Bomb Propeller - oldProjectileAsOrca = SPGrenadeProjectile(oldProjectile); - newProjectileAsOrca = SPGrenadeProjectile(newProjectile); - if (oldProjectileAsOrca != none && newProjectileAsOrca != none) - { - newProjectileAsOrca.impactDamage = oldProjectileAsOrca.impactDamage; - oldProjectileAsOrca.impactDamage = 0; - return; - } - // SealSqueal Harpoon Bomber - oldProjectileAsHarpoon = SealSquealProjectile(oldProjectile); - newProjectileAsHarpoon = SealSquealProjectile(newProjectile); - if (oldProjectileAsHarpoon != none && newProjectileAsHarpoon != none) - { - newProjectileAsHarpoon.impactDamage = - oldProjectileAsHarpoon.impactDamage; - oldProjectileAsHarpoon.impactDamage = 0; - return; - } -} - -defaultproperties -{ - relatedEvents = class'MutatorEvents' -} \ No newline at end of file diff --git a/sources/FixSpectatorCrash/BroadcastListener_FixSpectatorCrash.uc b/sources/FixSpectatorCrash/BroadcastListener_FixSpectatorCrash.uc deleted file mode 100644 index dc16c4a..0000000 --- a/sources/FixSpectatorCrash/BroadcastListener_FixSpectatorCrash.uc +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Overloaded broadcast events listener to catch the moment - * someone becomes alive player / spectator. - * Copyright 2020 Anton Tarasenko - *------------------------------------------------------------------------------ - * This file is part of Acedia. - * - * Acedia is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License, or - * (at your option) any later version. - * - * Acedia is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Acedia. If not, see . - */ -class BroadcastListener_FixSpectatorCrash extends BroadcastListenerBase - abstract; - -var private const int becomeAlivePlayerID; -var private const int becomeSpectatorID; - -static function bool HandleLocalized( - Actor sender, - BroadcastEvents.LocalizedMessage message -) -{ - local FixSpectatorCrash specFix; - local PlayerController senderController; - if (sender == none) return true; - if (sender.level == none || sender.level.game == none) return true; - if (message.class != sender.level.game.gameMessageClass) return true; - if ( message.id != default.becomeAlivePlayerID - && message.id != default.becomeSpectatorID) return true; - - specFix = FixSpectatorCrash(class'FixSpectatorCrash'.static.GetInstance()); - senderController = GetController(sender); - specFix.NotifyStatusChange(senderController); - return (!specFix.IsViolator(senderController)); -} - -defaultproperties -{ - becomeAlivePlayerID = 1 - becomeSpectatorID = 14 -} \ No newline at end of file diff --git a/sources/FixSpectatorCrash/FixSpectatorCrash.uc b/sources/FixSpectatorCrash/FixSpectatorCrash.uc index 838c7ae..d898530 100644 --- a/sources/FixSpectatorCrash/FixSpectatorCrash.uc +++ b/sources/FixSpectatorCrash/FixSpectatorCrash.uc @@ -1,11 +1,6 @@ /** - * This feature attempts to prevent server crashes caused by someone - * quickly switching between being spectator and an active player. - * - * We do so by disconnecting players who start switching way too fast - * (more than twice in a short amount of time) and temporarily faking a large - * amount of players on the server, to prevent such spam from affecting the server. - * Copyright 2020 Anton Tarasenko + * Config object for `FixSpectatorCrash_Feature`. + * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -22,276 +17,40 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixSpectatorCrash extends Feature - dependson(ConnectionService) +class FixSpectatorCrash extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * We use broadcast events to track when someone is switching - * to active player or spectator and remember such people - * for a short time (cooldown), defined by (`spectatorChangeTimeout`). - * If one of the player we've remembered tries to switch again, - * before the defined cooldown ran out, - we kick him - * by destroying his controller. - * One possible problem arises from the fact that controllers aren't - * immediately destroyed and instead initiate player disconnection, - - * exploiter might have enough time to cause a lag or even crash the server. - * We address this issue by temporarily blocking anyone from - * becoming active player (we do this by setting `numPlayers` variable in - * killing floor's game info to a large value). - * After all malicious players have successfully disconnected, - - * we remove the block. - */ - -// This fix will try to kick any player that switches between active player -// and cooldown faster than time (in seconds) in this value. -// NOTE: raising this value past default value of `0.25` -// won't actually improve crash prevention. -var private config const float spectatorChangeTimeout; - -// [ADVANCED] Don't change this setting unless you know what you're doing. -// Allows you to turn off server blocking. -// Players that don't respect timeout will still be kicked. -// This might be needed if this fix conflicts with another mutator -// that also changes `numPlayers`. -// However, it is necessary to block aggressive enough server crash attempts, -// but can cause compatibility issues with some mutators. -// It's highly preferred to rewrite such a mutator to be compatible. -// NOTE: it should be compatible with most faked players-type mutators, -// since this fix remembers the difference between amount of -// real players and `numPlayers`. -// After unblocking, it sets `numPlayers` to -// the current amount of real players + that difference. -// So 4 players + 3 (=7 numPlayers) after kicking 1 player becomes -// 3 players + 3 (=6 numPlayers). -var private config const bool allowServerBlock; - -// Stores remaining cooldown value before the next allowed -// spectator change per player. -struct CooldownRecord -{ - // Reference to `PlayerController` - var NativeActorRef player; - var float cooldown; -}; - -// Currently active cooldowns -var private array currentCooldowns; - -// Players who were decided to be violators and -// were marked for disconnecting. -// We'll be maintaining server block as long as even one -// of them hasn't yet disconnected. -// References to `PlayerController`s. -var private array violators; - -// Is server currently blocked? -var private bool becomingActiveBlocked; -// This value introduced to accommodate mods such as faked player that can -// change `numPlayers` to a value that isn't directly tied to the -// current number of active players. -// We remember the difference between active players and `numPlayers` -/// variable in game type before server block and add it after block is over. -// If some mod introduces a more complicated relation between amount of -// active players and `numPlayers`, then it must take care of -// compatibility on it's own. -var private int recordedNumPlayersMod; - -protected function OnEnabled() -{ - _.unreal.OnTick(self).connect = Tick; -} - -protected function OnDisabled() -{ - _.unreal.OnTick(self).Disconnect(); -} - -// If given `PlayerController` is registered in our cooldown records, - -// returns it's index. -// If it doesn't exists (or `none` value was passes), - returns `-1`. -private final function int GetCooldownIndex(PlayerController player) -{ - local int i; - if (player == none) return -1; - - for (i = 0; i < currentCooldowns.length; i += 1) - { - if (currentCooldowns[i].player.Get() == player) { - return i; - } - } - return -1; -} - -// Checks if given `PlayerController` is registered as a violator. -// `none` value isn't a violator. -public final function bool IsViolator(PlayerController player) -{ - local int i; - if (player == none) return false; - - for (i = 0; i < violators.length; i += 1) - { - if (violators[i].Get() == player) { - return true; - } - } - return false; -} - -// This function is to notify our fix that some player just changed status -// of active player / spectator. -// If passes value isn't `none`, it puts given player on cooldown or kicks him. -public final function NotifyStatusChange(PlayerController player) -{ - local int index; - local CooldownRecord newRecord; - if (player == none) return; - - index = GetCooldownIndex(player); - // Players already on cool down must be kicked and marked as violators - if (index >= 0) - { - player.Destroy(); - violators[violators.length] = currentCooldowns[index].player; - currentCooldowns.Remove(index, 1); - if (allowServerBlock) { - SetBlock(true); - } - } - // Players that aren't on cooldown are - // either violators (do nothing, just wait for their disconnect) - // or didn't recently change their status (put them on cooldown). - else if (!IsViolator(player)) - { - newRecord.player = _.unreal.ActorRef(player); - newRecord.cooldown = spectatorChangeTimeout; - currentCooldowns[currentCooldowns.length] = newRecord; - } -} - -// Pass `true` to block server, `false` to unblock. -// Only works if `allowServerBlock` is set to `true`. -private final function SetBlock(bool activateBlock) -{ - local KFGameType kfGameType; - // Do we even need to do anything? - if (!allowServerBlock) return; - if (activateBlock == becomingActiveBlocked) return; - - // Actually block/unblock - kfGameType = _.unreal.GetKFGameType(); - becomingActiveBlocked = activateBlock; - if (activateBlock) - { - recordedNumPlayersMod = GetNumPlayersMod(); - // This value both can't realistically fall below - // `kfGameType.maxPlayer` and won't overflow from random increase - // in vanilla code. - kfGameType.numPlayers = maxInt / 2; - } - else - { - // Adding `recordedNumPlayersMod` should prevent - // faked players from breaking. - kfGameType.numPlayers = GetRealPlayers() + recordedNumPlayersMod; - } -} - -// Performs server blocking if violators have disconnected. -private final function TryUnblocking() -{ - local int i; - if (!allowServerBlock) return; - if (!becomingActiveBlocked) return; - - for (i = 0; i < violators.length; i += 1) - { - if (violators[i].Get() != none) { - return; - } - } - _.memory.FreeMany(violators); - violators.length = 0; - SetBlock(false); -} - -// Counts current amount of "real" active players -// (connected to the server and not spectators). -// Need `ConnectionService` to be running, otherwise return `-1`. -private final function int GetRealPlayers() -{ - // Auxiliary variables - local int i; - local int realPlayersAmount; - local PlayerController player; - // Information extraction - local ConnectionService service; - local array connections; - service = ConnectionService(class'ConnectionService'.static.GetInstance()); - if (service == none) return -1; - - // Count non-spectators - connections = service.GetActiveConnections(); - realPlayersAmount = 0; - for (i = 0; i < connections.length; i += 1) - { - player = connections[i].controllerReference; - if (player == none) continue; - if (player.playerReplicationInfo == none) continue; - if (!player.playerReplicationInfo.bOnlySpectator) { - realPlayersAmount += 1; - } - } - return realPlayersAmount; -} +var public config float spectatorChangeTimeout; +var public config bool allowServerBlock; -// Calculates difference between current amount of "real" active players -// and `numPlayers` from `KFGameType`. -// Most typically this difference will be non-zero when using -// faked players-type mutators -// (difference will be equal to the amount of faked players). -private final function int GetNumPlayersMod() +protected function AssociativeArray ToData() { - return _.unreal.GetKFGameType().numPlayers - GetRealPlayers(); + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(P("allowServerBlock"), allowServerBlock, true); + data.SetFloat(P("spectatorChangeTimeout"), spectatorChangeTimeout, true); + return data; } -private final function ReduceCooldowns(float timePassed) +protected function FromData(AssociativeArray source) { - local int i; - i = 0; - while (i < currentCooldowns.length) - { - currentCooldowns[i].cooldown -= timePassed; - if ( currentCooldowns[i].player.Get() != none - && currentCooldowns[i].cooldown > 0.0) - { - i += 1; - } - else - { - currentCooldowns[i].player.FreeSelf(); - currentCooldowns.Remove(i, 1); - } + if (source == none) { + return; } + allowServerBlock = source.GetBool(P("allowServerBlock"), true); + spectatorChangeTimeout = source.GetFloat(P("spectatorChangeTimeout"), 0.25); } -private function Tick(float delta, float tileDilationCoefficient) +protected function DefaultIt() { - local float trueTimePassed; - trueTimePassed = delta / tileDilationCoefficient; - TryUnblocking(); - ReduceCooldowns(trueTimePassed); + spectatorChangeTimeout = 0.25; + allowServerBlock = true; } defaultproperties { - // Configurable variables + configName = "AcediaFixes" spectatorChangeTimeout = 0.25 allowServerBlock = true - // Inner variables - becomingActiveBlocked = false - // Listeners - requiredListeners(0) = class'BroadcastListener_FixSpectatorCrash' } \ No newline at end of file diff --git a/sources/FixSpectatorCrash/FixSpectatorCrash_Feature.uc b/sources/FixSpectatorCrash/FixSpectatorCrash_Feature.uc new file mode 100644 index 0000000..e253958 --- /dev/null +++ b/sources/FixSpectatorCrash/FixSpectatorCrash_Feature.uc @@ -0,0 +1,341 @@ +/** + * This feature attempts to prevent server crashes caused by someone + * quickly switching between being spectator and an active player. + * + * We do so by disconnecting players who start switching way too fast + * (more than twice in a short amount of time) and temporarily faking a large + * amount of players on the server, to prevent such spam from affecting the server. + * Copyright 2020 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixSpectatorCrash_Feature extends Feature + dependson(ConnectionService); + +/** + * We use broadcast events to track when someone is switching + * to active player or spectator and remember such people + * for a short time (cooldown), defined by (`spectatorChangeTimeout`). + * If one of the player we've remembered tries to switch again, + * before the defined cooldown ran out, - we kick him + * by destroying his controller. + * One possible problem arises from the fact that controllers aren't + * immediately destroyed and instead initiate player disconnection, - + * exploiter might have enough time to cause a lag or even crash the server. + * We address this issue by temporarily blocking anyone from + * becoming active player (we do this by setting `numPlayers` variable in + * killing floor's game info to a large value). + * After all malicious players have successfully disconnected, - + * we remove the block. + */ + +// This fix will try to kick any player that switches between active player +// and cooldown faster than time (in seconds) in this value. +// NOTE: raising this value past default value of `0.25` +// won't actually improve crash prevention. +var private config float spectatorChangeTimeout; + +// [ADVANCED] Don't change this setting unless you know what you're doing. +// Allows you to turn off server blocking. +// Players that don't respect timeout will still be kicked. +// This might be needed if this fix conflicts with another mutator +// that also changes `numPlayers`. +// However, it is necessary to block aggressive enough server crash attempts, +// but can cause compatibility issues with some mutators. +// It's highly preferred to rewrite such a mutator to be compatible. +// NOTE: it should be compatible with most faked players-type mutators, +// since this fix remembers the difference between amount of +// real players and `numPlayers`. +// After unblocking, it sets `numPlayers` to +// the current amount of real players + that difference. +// So 4 players + 3 (=7 numPlayers) after kicking 1 player becomes +// 3 players + 3 (=6 numPlayers). +var private config bool allowServerBlock; + +// Stores remaining cooldown value before the next allowed +// spectator change per player. +struct CooldownRecord +{ + // Reference to `PlayerController` + var NativeActorRef player; + var float cooldown; +}; + +// Currently active cooldowns +var private array currentCooldowns; + +// Players who were decided to be violators and +// were marked for disconnecting. +// We'll be maintaining server block as long as even one +// of them hasn't yet disconnected. +// References to `PlayerController`s. +var private array violators; + +// Is server currently blocked? +var private bool becomingActiveBlocked; +// This value introduced to accommodate mods such as faked player that can +// change `numPlayers` to a value that isn't directly tied to the +// current number of active players. +// We remember the difference between active players and `numPlayers` +/// variable in game type before server block and add it after block is over. +// If some mod introduces a more complicated relation between amount of +// active players and `numPlayers`, then it must take care of +// compatibility on it's own. +var private int recordedNumPlayersMod; + +// IDs of localized messages, ripped from sources +var private const int becomeAlivePlayerID; +var private const int becomeSpectatorID; + +protected function OnEnabled() +{ + _.unreal.OnTick(self).connect = Tick; + _.unreal.broadcasts.OnHandleLocalized(self).connect = HandleLocalized; +} + +protected function OnDisabled() +{ + _.unreal.OnTick(self).Disconnect(); + _.unreal.broadcasts.OnHandleLocalized(self).Disconnect(); +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixSpectatorCrash newConfig; + newConfig = FixSpectatorCrash(config); + if (newConfig == none) { + return; + } + spectatorChangeTimeout = newConfig.spectatorChangeTimeout; + allowServerBlock = newConfig.allowServerBlock; +} + +private function bool HandleLocalized( + Actor sender, + BroadcastAPI.LocalizedMessage message) +{ + local PlayerController senderController; + if (sender == none) return true; + if (sender.level == none || sender.level.game == none) return true; + if (message.class != sender.level.game.gameMessageClass) return true; + if ( message.id != default.becomeAlivePlayerID + && message.id != default.becomeSpectatorID) return true; + + senderController = GetController(sender); + NotifyStatusChange(senderController); + return (!IsViolator(senderController)); +} + +private final function PlayerController GetController(Actor sender) +{ + local Pawn senderPawn; + senderPawn = Pawn(sender); + if (senderPawn != none) { + return PlayerController(senderPawn.controller); + } + return PlayerController(sender); +} + +// If given `PlayerController` is registered in our cooldown records, - +// returns it's index. +// If it doesn't exists (or `none` value was passes), - returns `-1`. +private final function int GetCooldownIndex(PlayerController player) +{ + local int i; + if (player == none) return -1; + + for (i = 0; i < currentCooldowns.length; i += 1) + { + if (currentCooldowns[i].player.Get() == player) { + return i; + } + } + return -1; +} + +// Checks if given `PlayerController` is registered as a violator. +// `none` value isn't a violator. +public final function bool IsViolator(PlayerController player) +{ + local int i; + if (player == none) return false; + + for (i = 0; i < violators.length; i += 1) + { + if (violators[i].Get() == player) { + return true; + } + } + return false; +} + +// This function is to notify our fix that some player just changed status +// of active player / spectator. +// If passes value isn't `none`, it puts given player on cooldown or kicks him. +public final function NotifyStatusChange(PlayerController player) +{ + local int index; + local CooldownRecord newRecord; + if (player == none) return; + + index = GetCooldownIndex(player); + // Players already on cool down must be kicked and marked as violators + if (index >= 0) + { + player.Destroy(); + violators[violators.length] = currentCooldowns[index].player; + currentCooldowns.Remove(index, 1); + if (allowServerBlock) { + SetBlock(true); + } + } + // Players that aren't on cooldown are + // either violators (do nothing, just wait for their disconnect) + // or didn't recently change their status (put them on cooldown). + else if (!IsViolator(player)) + { + newRecord.player = _.unreal.ActorRef(player); + newRecord.cooldown = spectatorChangeTimeout; + currentCooldowns[currentCooldowns.length] = newRecord; + } +} + +// Pass `true` to block server, `false` to unblock. +// Only works if `allowServerBlock` is set to `true`. +private final function SetBlock(bool activateBlock) +{ + local KFGameType kfGameType; + // Do we even need to do anything? + if (!allowServerBlock) return; + if (activateBlock == becomingActiveBlocked) return; + + // Actually block/unblock + kfGameType = _.unreal.GetKFGameType(); + becomingActiveBlocked = activateBlock; + if (activateBlock) + { + recordedNumPlayersMod = GetNumPlayersMod(); + // This value both can't realistically fall below + // `kfGameType.maxPlayer` and won't overflow from random increase + // in vanilla code. + kfGameType.numPlayers = maxInt / 2; + } + else + { + // Adding `recordedNumPlayersMod` should prevent + // faked players from breaking. + kfGameType.numPlayers = GetRealPlayers() + recordedNumPlayersMod; + } +} + +// Performs server blocking if violators have disconnected. +private final function TryUnblocking() +{ + local int i; + if (!allowServerBlock) return; + if (!becomingActiveBlocked) return; + + for (i = 0; i < violators.length; i += 1) + { + if (violators[i].Get() != none) { + return; + } + } + _.memory.FreeMany(violators); + violators.length = 0; + SetBlock(false); +} + +// Counts current amount of "real" active players +// (connected to the server and not spectators). +// Need `ConnectionService` to be running, otherwise return `-1`. +private final function int GetRealPlayers() +{ + // Auxiliary variables + local int i; + local int realPlayersAmount; + local PlayerController player; + // Information extraction + local ConnectionService service; + local array connections; + service = ConnectionService(class'ConnectionService'.static.GetInstance()); + if (service == none) return -1; + + // Count non-spectators + connections = service.GetActiveConnections(); + realPlayersAmount = 0; + for (i = 0; i < connections.length; i += 1) + { + player = connections[i].controllerReference; + if (player == none) continue; + if (player.playerReplicationInfo == none) continue; + if (!player.playerReplicationInfo.bOnlySpectator) { + realPlayersAmount += 1; + } + } + return realPlayersAmount; +} + +// Calculates difference between current amount of "real" active players +// and `numPlayers` from `KFGameType`. +// Most typically this difference will be non-zero when using +// faked players-type mutators +// (difference will be equal to the amount of faked players). +private final function int GetNumPlayersMod() +{ + return _.unreal.GetKFGameType().numPlayers - GetRealPlayers(); +} + +private final function ReduceCooldowns(float timePassed) +{ + local int i; + i = 0; + while (i < currentCooldowns.length) + { + currentCooldowns[i].cooldown -= timePassed; + if ( currentCooldowns[i].player.Get() != none + && currentCooldowns[i].cooldown > 0.0) + { + i += 1; + } + else + { + currentCooldowns[i].player.FreeSelf(); + currentCooldowns.Remove(i, 1); + } + } +} + +private function Tick(float delta, float tileDilationCoefficient) +{ + local float trueTimePassed; + trueTimePassed = delta / tileDilationCoefficient; + TryUnblocking(); + ReduceCooldowns(trueTimePassed); +} + +defaultproperties +{ + configClass = class'FixSpectatorCrash' + // Configurable variables + spectatorChangeTimeout = 0.25 + allowServerBlock = true + // Inner variables + becomingActiveBlocked = false + // Ripped IDs of localized messages of interest + becomeAlivePlayerID = 1 + becomeSpectatorID = 14 +} \ No newline at end of file diff --git a/sources/FixZedTimeLags/FixZedTimeLags.uc b/sources/FixZedTimeLags/FixZedTimeLags.uc index 7813cc7..89a8e81 100644 --- a/sources/FixZedTimeLags/FixZedTimeLags.uc +++ b/sources/FixZedTimeLags/FixZedTimeLags.uc @@ -1,11 +1,6 @@ /** - * This feature fixes lags caused by a zed time that can occur - * on some maps when a lot of zeds are present at once. - * As a side effect it also fixes an issue where during zed time speed up - * `zedTimeSlomoScale` was assumed to be default value of `0.2`. - * Now zed time will behave correctly with mods that - * change `zedTimeSlomoScale`. - * Copyright 2020 Anton Tarasenko + * Config object for `FixZedTimeLags_Feature`. + * Copyright 2021 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -22,159 +17,42 @@ * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ -class FixZedTimeLags extends Feature - dependson(ConnectionService) +class FixZedTimeLags extends FeatureConfig + perobjectconfig config(AcediaFixes); -/** - * When zed time activates, game speed is immediately set to - * `zedTimeSlomoScale` (0.2 by default), defined, like all other variables, - * in `KFGameType`. Zed time lasts `zedTimeDuration` seconds (3.0 by default), - * but during last `zedTimeDuration * 0.166` seconds (by default 0.498) - * it starts to speed back up, causing game speed to update every tick. - * This makes animations look more smooth when exiting zed-time; - * however, updating speed every tick for that purpose seems like - * an overkill and, combined with things like - * increased tick rate, certain maps and raised zed limit, - * it can lead to noticeable lags at the end of zed time. - * To fix this issue we disable `Tick()` event in - * `KFGameType` and then repeat that functionality in our own `Tick()` event, - * but only perform game speed updates occasionally, - * to make sure that overall amount of updates won't go over a limit, - * that can be configured via `maxGameSpeedUpdatesAmount` - * Author's test (looking really hard on clots' animations) - * seem to suggest that there shouldn't be much visible difference if - * we limit game speed updates to about 2 or 3. - */ +var public config int maxGameSpeedUpdatesAmount; +var public config bool disableTick; -// Max amount of game speed updates during speed up phase -// (actual amount of updates can't be larger than amount of ticks). -// On servers with default 30 tick rate there's usually -// about 13 updates total on vanilla game. -// Values lower than 1 are treated like 1. -var private config const int maxGameSpeedUpdatesAmount; -// [ADVANCED] Don't change this setting unless you know what you're doing. -// Compatibility setting that allows to keep `GameInfo`'s `Tick()` event -// from being disabled. -// Useful when running Acedia along with custom `GameInfo` -// (that isn't `KFGameType`) that relies on `Tick()` event. -// Note, however, that in order to keep this fix working properly, -// it's on you to make sure `KFGameType.Tick()` logic isn't executed. -var private config const bool disableTick; -// Counts how much time is left until next update -var private float updateCooldown; - -protected function OnEnabled() +protected function AssociativeArray ToData() { - if (disableTick) { - _.unreal.GetGameType().Disable('Tick'); - } - _.unreal.OnTick(self).connect = Tick; + local AssociativeArray data; + data = __().collections.EmptyAssociativeArray(); + data.SetBool(P("disableTick"), disableTick, true); + data.SetInt(P("maxGameSpeedUpdatesAmount"), + maxGameSpeedUpdatesAmount, true); + return data; } -protected function OnDisabled() +protected function FromData(AssociativeArray source) { - if (disableTick) { - _.unreal.GetGameType().Enable('Tick'); - } - _.unreal.OnTick(self).Disconnect(); -} - -private function Tick(float delta, float timeDilationCoefficient) -{ - local KFGameType gameType; - local float trueTimePassed; - gameType = _.unreal.GetKFGameType(); - if (!gameType.bZEDTimeActive) return; - // Unfortunately we need to keep disabling `Tick()` probe function, - // because it constantly gets enabled back and I don't know where - // (maybe native code?); only really matters during zed time. - if (disableTick) { - gameType.Disable('Tick'); - } - // How much real (not in-game) time has passed - trueTimePassed = delta / timeDilationCoefficient; - gameType.currentZEDTimeDuration -= trueTimePassed; - - // Handle speeding up phase - if (gameType.bSpeedingBackUp) { - DoSpeedBackUp(trueTimePassed, gameType); - } - else if (gameType.currentZEDTimeDuration < GetSpeedupDuration(gameType)) - { - gameType.bSpeedingBackUp = true; - updateCooldown = GetFullUpdateCooldown(gameType); - TellClientsZedTimeEnds(); - DoSpeedBackUp(trueTimePassed, gameType); - } - // End zed time once it's duration has passed - if (gameType.currentZEDTimeDuration <= 0) - { - gameType.bZEDTimeActive = false; - gameType.bSpeedingBackUp = false; - gameType.zedTimeExtensionsUsed = 0; - gameType.SetGameSpeed(1.0); - } -} - -private final function TellClientsZedTimeEnds() -{ - local int i; - local KFPlayerController player; - local ConnectionService service; - local array connections; - service = ConnectionService(class'ConnectionService'.static.GetInstance()); - if (service == none) return; - connections = service.GetActiveConnections(); - for (i = 0; i < connections.length; i += 1) - { - player = KFPlayerController(connections[i].controllerReference); - if (player != none) - { - // Play sound of leaving zed time - player.ClientExitZedTime(); - } - } -} - -// This function is called every tick during speed up phase and manages -// gradual game speed increase. -private final function DoSpeedBackUp(float trueTimePassed, KFGameType gameType) -{ - // Game speed will always be updated in our `Tick()` event - // at the very end of the zed time. - // The rest of the updates will be uniformly distributed - // over the speed up duration. - - local float newGameSpeed; - local float slowdownScale; - if (maxGameSpeedUpdatesAmount <= 1) return; - if (updateCooldown > 0.0) - { - updateCooldown -= trueTimePassed; + if (source == none) { return; } - else { - updateCooldown = GetFullUpdateCooldown(gameType); - } - slowdownScale = - gameType.currentZEDTimeDuration / GetSpeedupDuration(gameType); - newGameSpeed = Lerp(slowdownScale, 1.0, gameType.zedTimeSlomoScale); - gameType.SetGameSpeed(newGameSpeed); -} - -private final function float GetSpeedupDuration(KFGameType gameType) -{ - return gameType.zedTimeDuration * 0.166; + disableTick = source.GetBool(P("disableTick"), true); + maxGameSpeedUpdatesAmount = + source.GetInt(P("maxGameSpeedUpdatesAmount"), 3); } -private final function float GetFullUpdateCooldown(KFGameType gameType) +protected function DefaultIt() { - return GetSpeedupDuration(gameType) / maxGameSpeedUpdatesAmount; + maxGameSpeedUpdatesAmount = 3; + disableTick = true; } defaultproperties { + configName = "AcediaFixes" maxGameSpeedUpdatesAmount = 3 disableTick = true } \ No newline at end of file diff --git a/sources/FixZedTimeLags/FixZedTimeLags_Feature.uc b/sources/FixZedTimeLags/FixZedTimeLags_Feature.uc new file mode 100644 index 0000000..c0ada7b --- /dev/null +++ b/sources/FixZedTimeLags/FixZedTimeLags_Feature.uc @@ -0,0 +1,191 @@ +/** + * This feature fixes lags caused by a zed time that can occur + * on some maps when a lot of zeds are present at once. + * As a side effect it also fixes an issue where during zed time speed up + * `zedTimeSlomoScale` was assumed to be default value of `0.2`. + * Now zed time will behave correctly with mods that + * change `zedTimeSlomoScale`. + * Copyright 2020 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixZedTimeLags_Feature extends Feature + dependson(ConnectionService); + +/** + * When zed time activates, game speed is immediately set to + * `zedTimeSlomoScale` (0.2 by default), defined, like all other variables, + * in `KFGameType`. Zed time lasts `zedTimeDuration` seconds (3.0 by default), + * but during last `zedTimeDuration * 0.166` seconds (by default 0.498) + * it starts to speed back up, causing game speed to update every tick. + * This makes animations look more smooth when exiting zed-time; + * however, updating speed every tick for that purpose seems like + * an overkill and, combined with things like + * increased tick rate, certain maps and raised zed limit, + * it can lead to noticeable lags at the end of zed time. + * To fix this issue we disable `Tick()` event in + * `KFGameType` and then repeat that functionality in our own `Tick()` event, + * but only perform game speed updates occasionally, + * to make sure that overall amount of updates won't go over a limit, + * that can be configured via `maxGameSpeedUpdatesAmount` + * Author's test (looking really hard on clots' animations) + * seem to suggest that there shouldn't be much visible difference if + * we limit game speed updates to about 2 or 3. + */ + +// Max amount of game speed updates during speed up phase +// (actual amount of updates can't be larger than amount of ticks). +// On servers with default 30 tick rate there's usually +// about 13 updates total on vanilla game. +// Values lower than 1 are treated like 1. +var private config int maxGameSpeedUpdatesAmount; +// [ADVANCED] Don't change this setting unless you know what you're doing. +// Compatibility setting that allows to keep `GameInfo`'s `Tick()` event +// from being disabled. +// Useful when running Acedia along with custom `GameInfo` +// (that isn't `KFGameType`) that relies on `Tick()` event. +// Note, however, that in order to keep this fix working properly, +// it's on you to make sure `KFGameType.Tick()` logic isn't executed. +var private config bool disableTick; +// Counts how much time is left until next update +var private float updateCooldown; + +protected function OnEnabled() +{ + if (disableTick) { + _.unreal.GetGameType().Disable('Tick'); + } + _.unreal.OnTick(self).connect = Tick; +} + +protected function OnDisabled() +{ + if (disableTick) { + _.unreal.GetGameType().Enable('Tick'); + } + _.unreal.OnTick(self).Disconnect(); +} + +protected function SwapConfig(FeatureConfig config) +{ + local FixZedTimeLags newConfig; + newConfig = FixZedTimeLags(config); + if (newConfig == none) { + return; + } + maxGameSpeedUpdatesAmount = newConfig.maxGameSpeedUpdatesAmount; + disableTick = newConfig.disableTick; +} + +private function Tick(float delta, float timeDilationCoefficient) +{ + local KFGameType gameType; + local float trueTimePassed; + gameType = _.unreal.GetKFGameType(); + if (!gameType.bZEDTimeActive) return; + // Unfortunately we need to keep disabling `Tick()` probe function, + // because it constantly gets enabled back and I don't know where + // (maybe native code?); only really matters during zed time. + if (disableTick) { + gameType.Disable('Tick'); + } + // How much real (not in-game) time has passed + trueTimePassed = delta / timeDilationCoefficient; + gameType.currentZEDTimeDuration -= trueTimePassed; + + // Handle speeding up phase + if (gameType.bSpeedingBackUp) { + DoSpeedBackUp(trueTimePassed, gameType); + } + else if (gameType.currentZEDTimeDuration < GetSpeedupDuration(gameType)) + { + gameType.bSpeedingBackUp = true; + updateCooldown = GetFullUpdateCooldown(gameType); + TellClientsZedTimeEnds(); + DoSpeedBackUp(trueTimePassed, gameType); + } + // End zed time once it's duration has passed + if (gameType.currentZEDTimeDuration <= 0) + { + gameType.bZEDTimeActive = false; + gameType.bSpeedingBackUp = false; + gameType.zedTimeExtensionsUsed = 0; + gameType.SetGameSpeed(1.0); + } +} + +private final function TellClientsZedTimeEnds() +{ + local int i; + local KFPlayerController player; + local ConnectionService service; + local array connections; + service = ConnectionService(class'ConnectionService'.static.GetInstance()); + if (service == none) return; + connections = service.GetActiveConnections(); + for (i = 0; i < connections.length; i += 1) + { + player = KFPlayerController(connections[i].controllerReference); + if (player != none) + { + // Play sound of leaving zed time + player.ClientExitZedTime(); + } + } +} + +// This function is called every tick during speed up phase and manages +// gradual game speed increase. +private final function DoSpeedBackUp(float trueTimePassed, KFGameType gameType) +{ + // Game speed will always be updated in our `Tick()` event + // at the very end of the zed time. + // The rest of the updates will be uniformly distributed + // over the speed up duration. + + local float newGameSpeed; + local float slowdownScale; + if (maxGameSpeedUpdatesAmount <= 1) return; + if (updateCooldown > 0.0) + { + updateCooldown -= trueTimePassed; + return; + } + else { + updateCooldown = GetFullUpdateCooldown(gameType); + } + slowdownScale = + gameType.currentZEDTimeDuration / GetSpeedupDuration(gameType); + newGameSpeed = Lerp(slowdownScale, 1.0, gameType.zedTimeSlomoScale); + gameType.SetGameSpeed(newGameSpeed); +} + +private final function float GetSpeedupDuration(KFGameType gameType) +{ + return gameType.zedTimeDuration * 0.166; +} + +private final function float GetFullUpdateCooldown(KFGameType gameType) +{ + return GetSpeedupDuration(gameType) / maxGameSpeedUpdatesAmount; +} + +defaultproperties +{ + configClass = class'FixZedTimeLags' + maxGameSpeedUpdatesAmount = 3 + disableTick = true +} \ No newline at end of file diff --git a/sources/Manifest.uc b/sources/Manifest.uc index 8f87fd2..dba95d5 100644 --- a/sources/Manifest.uc +++ b/sources/Manifest.uc @@ -22,15 +22,15 @@ defaultproperties { - features(0) = class'FixZedTimeLags' - features(1) = class'FixDoshSpam' - features(2) = class'FixFFHack' - features(3) = class'FixInfiniteNades' - features(4) = class'FixAmmoSelling' - features(5) = class'FixSpectatorCrash' - features(6) = class'FixDualiesCost' - features(7) = class'FixInventoryAbuse' - features(8) = class'FixProjectileFF' - features(9) = class'FixPipes' - features(10) = class'FixLogSpam' + features(0) = class'FixZedTimeLags_Feature' + features(1) = class'FixDoshSpam_Feature' + features(2) = class'FixFFHack_Feature' + features(3) = class'FixInfiniteNades_Feature' + features(4) = class'FixAmmoSelling_Feature' + features(5) = class'FixSpectatorCrash_Feature' + features(6) = class'FixDualiesCost_Feature' + features(7) = class'FixInventoryAbuse_Feature' + features(8) = class'FixProjectileFF_Feature' + features(9) = class'FixPipes_Feature' + features(10) = class'FixLogSpam_Feature' } \ No newline at end of file