You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
371 lines
16 KiB
371 lines
16 KiB
/** |
|
* 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, discount checks are the only 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 only side effect of such change. |
|
* 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 FixAmmoSelling 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 in 'ServerBuyAmmo' |
|
* function of 'KFPawn' (all the other places use it's default value instead). |
|
* This means that the only side-effect of our change is 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 |
|
* (its' 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. While for unreliable and for minuscule benefit, |
|
// this can potentially be abused by cheaters. |
|
// To decrease the amount of value they can get from it, this fix can be |
|
// allowed to decrease players' money into negative values. |
|
// The trade off is a small chance that a some bug in this fix and |
|
// an unlucky circumstances can lead to regular players |
|
// having negative dosh values. |
|
// Both situations are highly unlikely, but the option is there. |
|
var private config const 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; |
|
}; |
|
|
|
// All weapons we've detected so far. |
|
var private array<WeaponRecord> registeredWeapons; |
|
|
|
public function OnEnabled() |
|
{ |
|
local KFWeapon nextWeapon; |
|
local KFAmmoPickup nextPickup; |
|
// 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); |
|
} |
|
} |
|
|
|
public function OnDisabled() |
|
{ |
|
local int i; |
|
local AmmoPickupStalker nextStalker; |
|
local array<AmmoPickupStalker> stalkers; |
|
// 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; |
|
} |
|
} |
|
registeredWeapons.length = 0; |
|
// 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(); |
|
} |
|
} |
|
} |
|
|
|
// 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; |
|
if (potentialAbuser == none) return; |
|
|
|
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; |
|
return; |
|
} |
|
} |
|
} |
|
|
|
// Finds ammo instance for recorded weapon in it's owner's inventory. |
|
private 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'). |
|
private 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; |
|
// Check conditions from 'KFAmmoPickup' code ('Touch' function) |
|
if (pickup == none) return; |
|
if (pawnWithAmmo == none) return; |
|
if (pawnWithAmmo.controller == none) return; |
|
if (!pawnWithAmmo.bCanPickupInventory) return; |
|
if (!FastTrace(pawnWithAmmo.location, pickup.location)) return; |
|
|
|
// Add relevant amount of ammo to our records |
|
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; |
|
} |
|
} |
|
} |
|
|
|
event Tick(float delta) |
|
{ |
|
local int i; |
|
// For all the weapon records... |
|
i = 0; |
|
while (i < registeredWeapons.length) |
|
{ |
|
// ...remove dead records |
|
if (registeredWeapons[i].weapon == none) |
|
{ |
|
registeredWeapons.Remove(i, 1); |
|
continue; |
|
} |
|
// ...find ammo if it's missing |
|
if (registeredWeapons[i].ammo == none) |
|
{ |
|
registeredWeapons[i] = FindAmmoInstance(registeredWeapons[i]); |
|
} |
|
// ...tax for ammo, if we can |
|
registeredWeapons[i] = TaxAmmoChange(registeredWeapons[i]); |
|
i += 1; |
|
} |
|
} |
|
|
|
defaultproperties |
|
{ |
|
allowNegativeDosh = true; |
|
rules(0)=(abusableWeapon=class'KFMod.Crossbow',pickupReplacement=class'FixAmmoSellingClass_CrossbowPickup') |
|
rules(1)=(abusableWeapon=class'KFMod.PipeBombExplosive',pickupReplacement=class'FixAmmoSellingClass_PipeBombPickup') |
|
rules(2)=(abusableWeapon=class'KFMod.M79GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_M79Pickup') |
|
rules(3)=(abusableWeapon=class'KFMod.GoldenM79GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_GoldenM79Pickup') |
|
rules(4)=(abusableWeapon=class'KFMod.M32GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_M32Pickup') |
|
rules(5)=(abusableWeapon=class'KFMod.CamoM32GrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_CamoM32Pickup') |
|
rules(6)=(abusableWeapon=class'KFMod.LAW',pickupReplacement=class'FixAmmoSellingClass_LAWPickup') |
|
rules(7)=(abusableWeapon=class'KFMod.SPGrenadeLauncher',pickupReplacement=class'FixAmmoSellingClass_SPGrenadePickup') |
|
rules(8)=(abusableWeapon=class'KFMod.SealSquealHarpoonBomber',pickupReplacement=class'FixAmmoSellingClass_SealSquealPickup') |
|
rules(9)=(abusableWeapon=class'KFMod.SeekerSixRocketLauncher',pickupReplacement=class'FixAmmoSellingClass_SeekerSixPickup') |
|
// Listeners |
|
requiredListeners(0) = class'MutatorListener_FixAmmoSelling' |
|
} |