Anton Tarasenko
3 years ago
48 changed files with 3766 additions and 3062 deletions
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<KFWeapon> abusableWeapon; |
||||||
|
var class<KFWeaponPickup> pickupReplacement; |
||||||
|
}; |
||||||
|
|
||||||
|
// Actual list of abusable weapons. |
||||||
|
var private const array<ReplacementRule> 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<AmmoPickupStalker> stalkers; |
||||||
|
local array<WeaponRecord> 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<Actor> 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<WeaponRecord> 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<KFWeaponPickup> vanillaPickupClass, fixPickupClass; |
||||||
|
if (kfWeapon == none || kfWeapon.instigator == none) return 0.0; |
||||||
|
fixPickupClass = class<KFWeaponPickup>(kfWeapon.pickupClass); |
||||||
|
vanillaPickupClass = class<KFWeaponPickup>(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<WeaponRecord> 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<Inventory> 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' |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
|
||||||
*/ |
|
||||||
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<Inventory> 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' |
|
||||||
} |
|
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<DoshStreamPerPlayer> currentContributors; |
||||||
|
|
||||||
|
// Wads of cash that are lying around on the map. |
||||||
|
var private array<NativeActorRef> 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<DoshStreamPerPlayer> 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 |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
|
||||||
*/ |
|
||||||
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' |
|
||||||
} |
|
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<KFWeapon> single; |
||||||
|
var class<KFWeapon> dual; |
||||||
|
}; |
||||||
|
var private const array<DualiesPair> 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<WeaponValuePair> pendingValues; |
||||||
|
|
||||||
|
// Describe sell values of all currently existing single pistols. |
||||||
|
struct WeaponDataRecord |
||||||
|
{ |
||||||
|
// Reference to `KFWeapon` instance |
||||||
|
var NativeActorRef reference; |
||||||
|
var class<KFWeapon> 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<WeaponDataRecord> 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<KFWeapon> 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<KFWeaponPickup> pickupClass; |
||||||
|
local KFPlayerReplicationInfo instigatorRI; |
||||||
|
if (weapon == none) return 0.0; |
||||||
|
pickupClass = class<KFWeaponPickup>(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') |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
|
||||||
*/ |
|
||||||
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' |
|
||||||
} |
|
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<DamageType> > alwaysScale; |
||||||
|
// Damage types, for which we should never reapply friendly fire scaling. |
||||||
|
var private /*config*/ array< class<DamageType> > 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> 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> damageType) |
||||||
|
{ |
||||||
|
local int i; |
||||||
|
local array< class<DamageType> > 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' |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<FragAmmoRecord> 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<FragAmmoRecord> 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 |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
|
||||||
*/ |
|
||||||
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 |
|
||||||
{ |
|
||||||
} |
|
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<KFWeaponPickup> single; |
||||||
|
var class<KFWeaponPickup> 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<DualiesPair> 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<KFWeaponPickup> GetRootPickupClass(KFWeapon weapon) |
||||||
|
{ |
||||||
|
local int i; |
||||||
|
local class<KFWeaponPickup> root; |
||||||
|
if (weapon == none) return none; |
||||||
|
// Start with a pickup of the given weapons |
||||||
|
root = class<KFWeaponPickup>(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<KFWeaponPickup>(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<KFWeaponPickup> rootClass; |
||||||
|
local array< class<Pickup> > 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<KFWeapon> 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<ConnectionService.Connection> 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') |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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 |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
|
||||||
*/ |
|
||||||
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' |
|
||||||
} |
|
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<PipeRecord> 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 |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
|
||||||
*/ |
|
||||||
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' |
|
||||||
} |
|
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<ROBallisticProjectile> vulnerableClass; |
||||||
|
var class<ROBallisticProjectile> protectedClass; |
||||||
|
}; |
||||||
|
var private const array<ReplacementRule> 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<ROBallisticProjectile> 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<ROBallisticProjectile> 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<ROBallisticProjectile> FindFixedClass( |
||||||
|
class<ROBallisticProjectile> projectileClass) |
||||||
|
{ |
||||||
|
local int i; |
||||||
|
local array<ReplacementRule> 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') |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
|
||||||
*/ |
|
||||||
class MutatorListener_FixProjectileFF extends MutatorListenerBase |
|
||||||
abstract; |
|
||||||
|
|
||||||
static function bool CheckReplacement(Actor other, out byte isSuperRelevant) |
|
||||||
{ |
|
||||||
local ROBallisticProjectile projectile; |
|
||||||
local class<ROBallisticProjectile> 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<ROBallisticProjectile> 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' |
|
||||||
} |
|
@ -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 <https://www.gnu.org/licenses/>. |
|
||||||
*/ |
|
||||||
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 |
|
||||||
} |
|
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<CooldownRecord> 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<NativeActorRef> 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<ConnectionService.Connection> 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 |
||||||
|
} |
@ -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 <https://www.gnu.org/licenses/>. |
||||||
|
*/ |
||||||
|
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<ConnectionService.Connection> 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 |
||||||
|
} |
Loading…
Reference in new issue