/**
 *      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 = _server.unreal.GetLevel();
    _server.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 = _server.unreal.GetLevel();
    _server.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'
}