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.
447 lines
19 KiB
447 lines
19 KiB
3 years ago
|
/**
|
||
|
* 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'
|
||
|
}
|