diff --git a/sources/Features/FixDualiesCost/DualiesCostRule.uc b/sources/Features/FixDualiesCost/DualiesCostRule.uc
new file mode 100644
index 0000000..aa03156
--- /dev/null
+++ b/sources/Features/FixDualiesCost/DualiesCostRule.uc
@@ -0,0 +1,45 @@
+/**
+ * This rule detects any pickup events to allow us to
+ * properly record and/or fix pistols' prices.
+ * Copyright 2019 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class DualiesCostRule extends GameRules;
+
+function bool OverridePickupQuery
+(
+ Pawn other,
+ Pickup item,
+ out byte allowPickup
+)
+{
+ local KFWeaponPickup weaponPickup;
+ local FixDualiesCost dualiesCostFix;
+ weaponPickup = KFWeaponPickup(item);
+ dualiesCostFix = FixDualiesCost(class'FixDualiesCost'.static.GetInstance());
+ if (weaponPickup != none && dualiesCostFix != none)
+ {
+ dualiesCostFix.ApplyPendingValues();
+ dualiesCostFix.StoreSinglePistolValues();
+ dualiesCostFix.SetNextSellValue(weaponPickup.sellValue);
+ }
+ return super.OverridePickupQuery(other, item, allowPickup);
+}
+
+defaultproperties
+{
+}
\ No newline at end of file
diff --git a/sources/Features/FixDualiesCost/FixDualiesCost.uc b/sources/Features/FixDualiesCost/FixDualiesCost.uc
new file mode 100644
index 0000000..2fd3822
--- /dev/null
+++ b/sources/Features/FixDualiesCost/FixDualiesCost.uc
@@ -0,0 +1,454 @@
+/**
+ * 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 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class FixDualiesCost 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 const bool allowSellValueIncrease;
+
+// Describe all the possible pairs of dual pistols in a vanilla game.
+struct DualiesPair
+{
+ var class single;
+ var class dual;
+};
+var private const array dualiesClasses;
+
+// Describe sell values that need to be applied at earliest later point.
+struct WeaponValuePair
+{
+ var KFWeapon weapon;
+ var float value;
+};
+var private const array pendingValues;
+
+// Describe sell values of all currently existing single pistols.
+struct WeaponDataRecord
+{
+ var KFWeapon reference;
+ var class class;
+ var float value;
+ // The whole point of this structure is to remember value of a weapon
+ // after it's destroyed. Since 'reference' will become 'none' by then,
+ // we will use the 'owner' reference to identify the weapon.
+ var Pawn owner;
+};
+var private const array storedValues;
+
+// Sell value of the last seen pickup in 'OverridePickupQuery'
+var private int nextSellValue;
+
+public function OnEnabled()
+{
+ local KFWeapon nextWeapon;
+ // Find all frags, that spawned when this fix wasn't running.
+ foreach level.DynamicActors(class'KFMod.KFWeapon', nextWeapon)
+ {
+ RegisterSinglePistol(nextWeapon, false);
+ }
+ level.game.AddGameModifier(Spawn(class'DualiesCostRule'));
+}
+
+public function OnDisabled()
+{
+ local GameRules rulesIter;
+ local DualiesCostRule ruleToDestroy;
+ // Check first rule
+ if (level.game.gameRulesModifiers == none) return;
+
+ ruleToDestroy = DualiesCostRule(level.game.gameRulesModifiers);
+ if (ruleToDestroy != none)
+ {
+ level.game.gameRulesModifiers = ruleToDestroy.nextGameRules;
+ ruleToDestroy.Destroy();
+ return;
+ }
+ // Check rest of the rules
+ rulesIter = level.game.gameRulesModifiers;
+ while (rulesIter != none)
+ {
+ ruleToDestroy = DualiesCostRule(rulesIter.nextGameRules);
+ if (ruleToDestroy != none)
+ {
+ rulesIter.nextGameRules = ruleToDestroy.nextGameRules;
+ ruleToDestroy.Destroy();
+ }
+ rulesIter = rulesIter.nextGameRules;
+ }
+}
+
+public final function SetNextSellValue(int newValue)
+{
+ nextSellValue = newValue;
+}
+
+// Finds a weapon of a given class in given 'Pawn' 's inventory.
+// Returns 'none' if weapon isn't there.
+private final function KFWeapon GetWeaponOfClass
+(
+ Pawn playerPawn,
+ class weaponClass
+)
+{
+ local Inventory invIter;
+ if (playerPawn == none) return none;
+
+ invIter = playerPawn.inventory;
+ while (invIter != none)
+ {
+ if (invIter.class == weaponClass)
+ {
+ return KFWeapon(invIter);
+ }
+ invIter = invIter.inventory;
+ }
+ return none;
+}
+
+// Gets weapon index in our record of dual pistol classes.
+// Second variable determines whether we're searching for single
+// or dual variant:
+// ~ 'true' - searching for single
+// ~ 'false' - for dual
+// Returns '-1' if weapon isn't found
+// (dual MK23 won't be found as a single weapon).
+private final function int GetIndexAs(KFWeapon weapon, bool asSingle)
+{
+ local int i;
+ if (weapon == none) return -1;
+
+ for (i = 0; i < dualiesClasses.length; i += 1)
+ {
+ if (asSingle && dualiesClasses[i].single == weapon.class)
+ {
+ return i;
+ }
+ if (!asSingle && dualiesClasses[i].dual == weapon.class)
+ {
+ return i;
+ }
+ }
+ return -1;
+}
+
+// Calculates full cost of a weapon with a discount,
+// dependent on it's instigator's perk.
+private final function float GetFullCost(KFWeapon weapon)
+{
+ local float cost;
+ local class pickupClass;
+ local KFPlayerReplicationInfo instigatorRI;
+ if (weapon == none) return 0.0;
+ pickupClass = class(weapon.default.pickupClass);
+ if (pickupClass == none) return 0.0;
+
+ cost = pickupClass.default.cost;
+ if (weapon.instigator != none)
+ {
+ instigatorRI =
+ KFPlayerReplicationInfo(weapon.instigator.playerReplicationInfo);
+ }
+ if (instigatorRI != none && instigatorRI.clientVeteranSkill != none)
+ {
+ cost *= instigatorRI.clientVeteranSkill.static
+ .GetCostScaling(instigatorRI, pickupClass);
+ }
+ return cost;
+}
+
+// If passed weapon is a pistol - we start tracking it's value;
+// Otherwise - do nothing.
+public final function RegisterSinglePistol
+(
+ KFWeapon singlePistol,
+ bool justSpawned
+)
+{
+ local WeaponDataRecord newRecord;
+ if (singlePistol == none) return;
+ if (GetIndexAs(singlePistol, true) < 0) return;
+
+ newRecord.reference = singlePistol;
+ newRecord.class = singlePistol.class;
+ newRecord.owner = 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 = 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 != none) continue;
+ if (storedValues[i].class != dualiesClasses[index].single) continue;
+ if (storedValues[i].owner != dualPistols.instigator) continue;
+ newPendingValue.weapon = dualPistols;
+ newPendingValue.value = storedValues[i].value + nextSellValue;
+ pendingValues[pendingValues.length] = newPendingValue;
+ break;
+ }
+}
+
+public final function ApplyPendingValues()
+{
+ local int i;
+ for (i = 0; i < pendingValues.length; i += 1)
+ {
+ if (pendingValues[i].weapon == 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 || pendingValues[i].weapon.sellValue == -1)
+ {
+ pendingValues[i].weapon.sellValue = pendingValues[i].value;
+ }
+ }
+ pendingValues.length = 0;
+}
+
+public final function StoreSinglePistolValues()
+{
+ local int i;
+ i = 0;
+ while (i < storedValues.length)
+ {
+ if (storedValues[i].reference == none)
+ {
+ storedValues.Remove(i, 1);
+ continue;
+ }
+ storedValues[i].owner = storedValues[i].reference.instigator;
+ storedValues[i].value = storedValues[i].reference.sellValue;
+ i += 1;
+ }
+}
+
+event Tick(float delta)
+{
+ ApplyPendingValues();
+ StoreSinglePistolValues();
+}
+
+defaultproperties
+{
+ allowSellValueIncrease = true
+ // Inner variables
+ dualiesClasses(0)=(single=class'KFMod.Single',dual=class'KFMod.Dualies')
+ dualiesClasses(1)=(single=class'KFMod.Magnum44Pistol',dual=class'KFMod.Dual44Magnum')
+ dualiesClasses(2)=(single=class'KFMod.MK23Pistol',dual=class'KFMod.DualMK23Pistol')
+ dualiesClasses(3)=(single=class'KFMod.Deagle',dual=class'KFMod.DualDeagle')
+ dualiesClasses(4)=(single=class'KFMod.GoldenDeagle',dual=class'KFMod.GoldenDualDeagle')
+ dualiesClasses(5)=(single=class'KFMod.FlareRevolver',dual=class'KFMod.DualFlareRevolver')
+ // Listeners
+ requiredListeners(0) = class'MutatorListener_FixDualiesCost'
+}
\ No newline at end of file
diff --git a/sources/Features/FixDualiesCost/MutatorListener_FixDualiesCost.uc b/sources/Features/FixDualiesCost/MutatorListener_FixDualiesCost.uc
new file mode 100644
index 0000000..0f209ae
--- /dev/null
+++ b/sources/Features/FixDualiesCost/MutatorListener_FixDualiesCost.uc
@@ -0,0 +1,43 @@
+/**
+ * Overloaded mutator events listener to catch when pistol-type weapons
+ * (single or dual) are spawned and to correct their price.
+ * Copyright 2020 Anton Tarasenko
+ *------------------------------------------------------------------------------
+ * This file is part of Acedia.
+ *
+ * Acedia is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Acedia is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Acedia. If not, see .
+ */
+class MutatorListener_FixDualiesCost extends MutatorListenerBase
+ abstract;
+
+static function bool CheckReplacement(Actor other, out byte isSuperRelevant)
+{
+ local KFWeapon weapon;
+ local FixDualiesCost dualiesCostFix;
+ weapon = KFWeapon(other);
+ if (weapon == none) return true;
+ dualiesCostFix = FixDualiesCost(class'FixDualiesCost'.static.GetInstance());
+ if (dualiesCostFix == none) return true;
+
+ dualiesCostFix.RegisterSinglePistol(weapon, true);
+ dualiesCostFix.FixCostAfterThrow(weapon);
+ dualiesCostFix.FixCostAfterBuying(weapon);
+ dualiesCostFix.FixCostAfterPickUp(weapon);
+ return true;
+}
+
+defaultproperties
+{
+ relatedEvents = class'MutatorEvents'
+}
\ No newline at end of file