Mutators & UnrealScript

ServerPerks Perk Coding: Weapon Bonuses, Starting Gear, Custom Perks and a New Level Requirement

On custom Killing Floor 1 servers, perks almost always come from ServerPerks: each perk is its own SRVet*.uc class extending SRVeterancyTypes in the ServerPerksP package. This page collects four threads from the killingfloor.ru forum (2011-2012): weapon bonuses (kok-s), starting weapons (NeKRoN), building your own perk (akf817gp) and adding a new levelling requirement (Flame). You need the ServerPerks sources for your version and the ability to compile packages.

Weapon bonuses

Each function in a perk class handles one kind of bonus. The pattern is always the same: check which weapon the call is for, then which level, then return a multiplier. To give a perk another perk's bonus (for example, the Medic's armor for the Berserker), find that bonus's function in the other perk's class, copy it into your perk's class and adjust the values; put the new function before the defaultproperties block ("How to add another perk's skill to a perk?" thread, killingfloor.ru forum).

BonusFunctionWeapon checkReturns
Magazine sizeGetMagCapacityModAK47AssaultRifle(Other) != nonemultiplier, e.g. 1.10
Carried ammoAddExtraAmmoForAmmoType == class'AK47Ammo'multiplier
Ammo from boxesGetAmmoPickupModAK47Ammo(Other) != nonemultiplier
DamageAddDamageDmgType == class'DamTypeAK47AssaultRifle'float(InDamage) * 1.05 or a formula
Weapon discountGetCostScalingItem == class'AK47Pickup'price fraction
Ammo discountGetAmmoCostScalingItem == class'AK47Pickup'price fraction
RecoilModifyRecoilSpreadAK47AssaultRifle(Other.Weapon) != noneRecoil = 0.90 (lower is less recoil)
Reload speedGetReloadSpeedModifierAK47AssaultRifle(Other) != nonemultiplier
Fire rateGetFireSpeedModAK47AssaultRifle(Other) != nonemultiplier

The level comes from KFPRI.ClientVeteranSkillLevel:

if ( KFPRI.ClientVeteranSkillLevel == 1 )
    return 1.10;

or from a formula, like the stock Commando damage bonus:

return float(InDamage) * (1.00 + (0.10 * float(Min(KFPRI.ClientVeteranSkillLevel, 5))));

The stock ammo discount is FMax(0.25 - (0.03 * float(KFPRI.ClientVeteranSkillLevel)), 0.01f). Code varies a little between SRVet* classes, so find the function and edit by analogy.

To avoid a dependency on the weapon's package (and the compile order below), check custom weapons by name instead of class: Other.Weapon.IsA('PKM'), DmgType.Name == 'DamTypePKM', AmmoType.Name == 'PKMAmmo'.

Ammo in a thrown weapon. Flame's breakdown from the "Сохранение патронов выброшенного оружия" thread (killingfloor.ru forum): when a player throws a weapon, the Weapon subclass object is destroyed and a WeaponPickup subclass is created, which stores the ammo count (WeaponPickup.AmmoAmount[]). Picking it up works the other way round: a Weapon is created with no ammo, and the saved ammo is added later, ending in Engine.Ammunition.AddAmmo, which has AmmoAmount = Min(MaxAmmo, AmmoAmount+AmmoToAdd);. That is why a crossbow that carried 50 bolts thanks to AddExtraAmmoFor used to be cut down to MaxAmmo (36, from KFMod.CrossbowAmmo) after being thrown and picked up again. Flame later added to the thread that current versions of the KF code no longer have this problem and his fix mutator is no longer needed.

Starting weapons

AddDefaultInventory hands out starting gear. Each CreateInventoryVeterancy line gives one item; the second argument is its sell price after the perk discount. To give two guns at level 6, add a second line to the same condition:

static function AddDefaultInventory(KFPlayerReplicationInfo KFPRI, Pawn P)
{
    if ( KFPRI.ClientVeteranSkillLevel == 5 )
        KFHumanPawn(P).CreateInventoryVeterancy("KFMod.Shotgun", GetCostScaling(KFPRI, class'ShotgunPickup'));
    if ( KFPRI.ClientVeteranSkillLevel >= 6 )
    {
        KFHumanPawn(P).CreateInventoryVeterancy("KFMod.BoomStick", GetCostScaling(KFPRI, class'BoomStickPickup'));
        KFHumanPawn(P).CreateInventoryVeterancy("KFMod.FNFAL_ACOG_AssaultRifle", GetCostScaling(KFPRI, class'FNFAL_ACOG_Pickup'));
    }
}

Mind the weight limit: a starting weapon that does not fit drops to the ground.

== or >=. From the "Прописывание оружия !!!" (Assigning weapons) thread on the killingfloor.ru forum, Винни's explanation: a condition with == gives the weapon only at that exact level, >= at that level and every level after it. So with conditions >= 10 and >= 20, both fire at level 20 and the player also gets the level 10 weapon. To swap the weapon at a certain level, list the lower levels with == (in his example, the MP7M at levels 6, 7 and 8) and use >= for the top one (>= 9, the MP5M). He said he noticed no difference between KFHumanPawn(P).CreateInventoryVeterancy(...) and AddPerkedWeapon(...).

Your own perk

  1. Base. Copy the closest perk, for example SRVetSupportSpec.uc, into your package's Classes folder and rename the file and class: class A3MNC extends SRVeterancyTypes abstract;.
  2. Levelling. GetPerkProgressInt sets, per level (case 0, case 1, ...), the threshold for each requirement (ReqNum), and at the end returns the player's current value:
static function int GetPerkProgressInt( ClientPerkRepLink StatOther, out int FinalInt, byte CurLevel, byte ReqNum )
{
    switch( CurLevel )
    {
    case 0:
        if( ReqNum==0 ) FinalInt = 50;
        if( ReqNum==1 ) FinalInt = 100;
        if( ReqNum==2 ) FinalInt = 10000;
        break;
    // ... case 1 - case 10 ...
    default:
        if( ReqNum==0 ) FinalInt = 2000+GetDoubleScaling(CurLevel,200);
        if( ReqNum==1 ) FinalInt = 1100+GetDoubleScaling(CurLevel,100);
        if( ReqNum==2 ) FinalInt = 400000+GetDoubleScaling(CurLevel,40000);
        break;
    }
    if( ReqNum==0 )
        return Min(StatOther.RSelfHealsStat,FinalInt);
    if( ReqNum==1 )
        return Min(StatOther.RDamageHealedStat,FinalInt);
    return Min(StatOther.RShotgunDamageStat,FinalInt);
}

The available counters are listed in ServerStStats.RepCopyStats(): RDamageHealedStat, RWeldingPointsStat, RShotgunDamageStat, RHeadshotKillsStat, RStalkerKillsStat, RBullpupDamageStat, RMeleeDamageStat, RFlameThrowerDamageStat, RSelfHealsStat, RSoleSurvivorWavesStat, RCashDonatedStat, RFeedingKillsStat, RBurningCrossbowKillsStat, RGibbedFleshpoundsStat, RStalkersKilledWithExplosivesStat, RGibbedEnemiesStat, RBloatKillsStat, RTotalZedTimeStat, RSirenKillsStat, RKillsStat, RExplosivesDamageStat. Levels above the last case are handled by default through GetDoubleScaling.

  1. Text and icons in defaultproperties:
NumRequirements=3
PerkIndex=1
OnHUDIcon=Texture'MyPerkTex.Perk_Icons.MyPerk'
OnHUDGoldIcon=Texture'MyPerkTex.Perk_Icons.MyPerk_Gold'
VeterancyName="Medic-Support"
Requirements(0)="Heal yourself %x times"
Requirements(1)="Heal %x HP on teammates"
Requirements(2)="Deal %x damage with shotguns"
SRLevelEffects(0)="10% more damage with Shotguns|..."
CustomLevelInfo="%s more damage with Shotguns|..."

NumRequirements must match the number of requirements, Requirements(N) is the text for ReqNum==N, SRLevelEffects(N) describes level N's bonuses in the menu, and CustomLevelInfo is the template for levels beyond those listed. The guide's author advises keeping PerkIndex (the perk number) as in the original class.

If every level above 6 shows the same text in the menu (for example "50% more damage" for the Commando), the cause is CustomLevelInfo: only placeholders such as %s, %d, %r and %z change there, because GetCustomLevelInfo replaces them via ReplaceText with values computed from the level, while a number typed into the string as text ("50%") stays the same. There are two fixes: continue the list with SRLevelEffects(7)="75% more damage with Assault/Battle Rifles|..." and so on for every level (tedious if there are many levels), or replace the fixed numbers in CustomLevelInfo with such placeholders (Vinni, "Commando perk stats (ServerPerks)" thread, killingfloor.ru forum).

  1. Icon. Export a stock icon from KillingFloorHUD.utx (Perks group) in the KF SDK, draw your own with an alpha channel (white is the visible part), save it as DDS (DXT with alpha) and import it into your own .utx, for example into a Perk_Icons group. You need two icons: normal (red) and gold. See How to make your own ServerPerksV7 perk icons.

  2. Hooking it up. Compile the package, add it to ServerPackages and register the perk in ServerPerks.ini with a Perks=PackageName.ClassName line next to the stock ones. A ready perk from someone else's mod is added the same way.

A new levelling requirement: Siren kills with a machine gun

Flame's example: Commando reaches levels 5 and 6 only after 20 and 50 Sirens killed with the machine gun. The new stat has to be stored, replicated to the client, shown in the menu and incremented on a kill.

  1. ServerPerksMut.StatsObject. Add var config int MGSirenKillsStat; and append the field to the end of the string in GetSaveData() and to the end of parsing in SetSaveData() (MGSirenKillsStat = GetNextValue(S);). With a remote database, always put new fields at the end, or existing records shift.
  2. ServerPerks.ClientPerkRepLink. var int RMGSirenKillsStat; and its replication: reliable if( Role==ROLE_Authority && bNetOwner ) RMGSirenKillsStat;.
  3. ServerPerksMut.ServerStStats. Add Rep.RMGSirenKillsStat = MyStatsObject.MGSirenKillsStat; to RepCopyStats() and a counter function:
function AddMGSirenKill()
{
    bHasChanged = true;
    Rep.RMGSirenKillsStat++;
    if( MyStatsObject!=None )
        MyStatsObject.MGSirenKillsStat++;
    DelayedStatCheck();
}
  1. ServerPerks.SRStatList. In InitList() add StatProgress[27] = string(L.RMGSirenKillsStat);, and in defaultproperties add ProgressName(27)="Siren kills with MG" (use the next free index).
  2. The perk. In SRVetCommando.GetPerkProgressInt add a third requirement (ReqNum==2): 0 for levels 0-4, 20 for level 5, 50 for 6 and up, and return Min(StatOther.RMGSirenKillsStat, FinalInt). In defaultproperties: NumRequirements=3 and Requirements(2)="Kill %x Sirens with PKM".
  3. The weapon. Count the kill in the machine gun's damage type:
static function AwardKill(KFSteamStatsAndAchievements KFStatsAndAchievements, KFPlayerController Killer, KFMonster Killed)
{
    if( Killed.IsA('ZombieStalker') )
        KFStatsAndAchievements.AddStalkerKill();
    if( Killed.IsA('ZombieSiren') )
        ServerStStats(KFStatsAndAchievements).AddMGSirenKill();
}

Compile order

The machine gun calls ServerStStats from ServerPerksMut, so its EditPackages line must come after EditPackages=ServerPerksMut (or the weapon classes go straight into ServerPerksMut). The perk package ServerPerksP compiles before ServerPerksMut, which is why weapons are checked by name there (IsA('PKM'), DmgType.Name == 'DamTypePKM') rather than by class. Delete the old ServerPerks.u, ServerPerksP.u and ServerPerksMut.u before rebuilding.

Armor, resistances, the Commando and building ServerPerks 5

From the killingfloor.ru forum threads "Отслеживание брони игрока" (Tracking player armor), "Berserker resistance", "Вопросы по ServerPerks и его модификациям." (Questions about ServerPerks and its modifications) and "Помогите с компиляцией сп5" (Help compiling SP5).

Armor protection. How much armor reduces damage is set by the perk class's static function GetBodyArmorDamageModifier (Freddy's advice). VETAL's version for a Medic with levels above 6: 10% per level up to level 8, a fixed 0.10 from level 9:

static function float GetBodyArmorDamageModifier(KFPlayerReplicationInfo KFPRI)
{
 if ( KFPRI.ClientVeteranSkillLevel <= 8 )
 return 1.0 - (0.10 * float(KFPRI.ClientVeteranSkillLevel));
 if ( KFPRI.ClientVeteranSkillLevel >= 9 )
 return 0.10;
 return 1.00;
}

Resistances. A perk's damage resistances (for example the Berserker's resistance to Bloat bile DamTypeVomit and the general resistance) are set by the ReduceDamage function in its class, for example in SRVetBerserker:

static function int ReduceDamage(KFPlayerReplicationInfo KFPRI, KFPawn Injured, KFMonster DamageTaker, int InDamage, class<DamageType> DmgType)

If the Berserker's resistances do not work, first check that this function exists in SRVetBerserker. According to 3xzet, in ServerPerks the resistance reset to 1 after level 6, and the function had to be edited for levels above 6. The error Unexpected end of file at end of Class after such an edit means the numbers of opening and closing curly braces do not match.

Commando zed health display range. In SRVetCommando.SpecialHUDInfo the distance is only set for levels 1-6 (case 5: and case 6: give MaxDistanceSquared = 640000; // 100% (800 units)). LLIePLLIeHb added a branch for higher levels at the end of the same switch:

 default:
 MaxDistanceSquared = 921600; // 120% (960 units)
 break;

Building ServerPerks 5 from source. ServerPerks 5 consists of three linked packages, ServerPerks.u, ServerPerksP.u and ServerPerksMut.u; they have to be decompiled and built together. In [Editor.EditorEngine], after the last EditPackages= line, Pascal listed them in this order (the order matters):

EditPackages=ServerPerks
EditPackages=ServerPerksP
EditPackages=ServerPerksMut

If the build fails with Error, Redefinition of 'function AddKill' differs from original in KFSteamStatsAndAchievements even though nothing in the code was changed, then per an answer on the Tripwire forum (found by VETAL) delete the AddKill, AddBloatKill and AddSirenKill functions from ServerStStats.uc, plus the line:

function KilledFleshpound(bool bWithMeleeAttack, bool bWithAA12, bool bWithKnife);

Headshot damage, the boss wave, damage types and weapon pickup

Based on the killingfloor.ru forum threads "Вопрос по adddamage", "Ссылка на волну с патриком", "Регенерация хп или новый бонус для перка", "Прошу помощи...", "Linux+ServerPerks", "Хэдшотный урон.", "Подбор оружия", "[цикл статей] Разное" and "[цикл статей] Практическое использование мутатора VipItemMut".

AddDamage or GetHeadShotDamMulti (Flame's breakdown). In KFMonster.TakeDamage, AddDamage is called first for any damage; then, if the zed is already headless or bIsHeadShot==true (and the damage is not DamTypeMelee), the damage is multiplied by GetHeadShotDamMulti:

Damage = KFPRI.ClientVeteranSkill.Static.AddDamage(KFPRI, self, KFPawn(instigatedBy), Damage, DamageType);
...
Damage = float(Damage) * KFPRI.ClientVeteranSkill.Static.GetHeadShotDamMulti(KFPRI, KFPawn(instigatedBy), DamageType);

A headshot damage bonus belongs in GetHeadShotDamMulti. The M99 (and the crossbow) has a separate damage type for head hits: DamTypeM99HeadShot is set in DamageTypeHeadShot in the defaultproperties of M99Bullet, so check for that one when matching by name. Headshot damage is also multiplied by the bullet's HeadShotDamageMult and the coefficient of the same name in the DamageType.

Level in static functions. Static perk functions have no Level; take it from the parameters: KFPRI.Level, P.Level, DamageTaker.Level (Flame). A local variable is created anew on every call, so logic like "heal once per second" does not work in a static function: move it, for example, into SRHumanPawn with a global variable (Dr_Killjoy). To switch a bonus off during the boss wave, use bWaveBossInProgress at the start of the function:

if(KFGameType(DamageTaker.Level.Game).bWaveBossInProgress) return InDamage * 1.0;

In GetHeadShotDamMulti the same is done through KFGameType(P.Level.Game).bWaveBossInProgress.

Your own description for every level. If your SRLevelEffects/CustomLevelInfo edits do not show up in game, Flame advises overriding static function string GetCustomLevelInfo( byte cLevel ) in the perk class and, in switch( cLevel ), assigning a ready description string to S for each case, for example "10% more HeadShotdamage with heavy rifles|...|Spawn with a VSS".

Levelling stuck at one level. Stat values are stored as int, so nothing above 2147483647 (2^31-1) can be written to the stats (BAHO, Flame). The author of the "Linux+ServerPerks" thread, with hand-written thresholds in GetPerkProgressInt, stopped gaining levels at 34 even with a full progress bar; the fix was to remove RequirementScaling=1.000000 from ServerPerks along with every place that multiplies by it.

Headshot damage for Sharpshooter levelling. Dr_Killjoy's GameRules count only headshot damage from Sharpshooter weapons into AddSniperDamage; the damage type only needs bSniperWeapon set:

class DamageRegisterGameRule extends GameRules;
function int NetDamage( int OriginalDamage, int Damage, pawn injured, pawn instigatedBy, vector HitLocation, out vector Momentum, class<DamageType> DamageType )
{
 local class<KFWeaponDamageType> KFWDT;
 local ServerStStats InstigatedByStats;
 local KFMonster KFM;
 local int AwardDamage;
 if ( NextGameRules != None )
 Damage = NextGameRules.NetDamage( OriginalDamage,Damage,injured,instigatedBy,HitLocation,Momentum,DamageType );
 if ( Damage <= 0 ) return Damage;
 KFM = KFMonster(Injured);
 KFWDT = class<KFWeaponDamageType>(DamageType);
 InstigatedByStats = Static.GetServerStStats(InstigatedBy);
 if ( KFM != none && Static.IsSharpDamageType(KFWDT) && InstigatedByStats != none
 && ( KFM.bDecapitated || KFM.IsHeadshot(HitLocation,Momentum,1.00) ) )
 {
 AwardDamage = Clamp(Damage,1,Injured.Health);
 InstigatedByStats.AddSniperDamage(AwardDamage);
 }
 return Damage;
}
static function ServerStStats GetServerStStats(Pawn P)
{
 local PlayerController PC;
 local SteamStatsAndAchievementsBase KFStats;
 if ( P == none ) return none;
 if ( P.Controller != none ) PC = PlayerController(P.Controller);
 if ( PC == none ) return none;
 if ( PC.SteamStatsAndAchievements != none ) KFStats = PC.SteamStatsAndAchievements;
 if ( KFStats == none ) return none;
 return ServerStStats(KFStats);
}
static function bool IsSharpDamageType(class<KFWeaponDamageType> KFWDT)
{
 local class<KFWeapon> KFW;
 local class<KFWeaponPickup> KFWP;
 if ( KFWDT == none || !KFWDT.default.bSniperWeapon || !KFWDT.bCheckForHeadshots ) return false;
 KFW = class<KFWeapon>(KFWDT.default.WeaponClass);
 if ( KFW == none ) return true;
 KFWP = class<KFWeaponPickup>(KFW.default.PickupClass);
 return KFWP == none || KFWP.default.CorrespondingPerkIndex == class'KFVetSharpshooter'.default.PerkIndex;
}

The GameRules are started from a mutator. Spawning them is not enough: they must be added to the chain, or the calls never reach them (Dr_Killjoy's example; put your own class in place of FFPenaltyRules):

function PostBeginPlay()
{
 local GameRules G;
 Super.PostBeginPlay();
 G = spawn(class'FFPenaltyRules');
 if ( Level.Game.GameRulesModifiers == None )
 Level.Game.GameRulesModifiers = G;
 else Level.Game.GameRulesModifiers.AddGameRules(G);
}

Only a specific perk can pick a weapon up (HATAXA, Flame). Override OverridePickupQuery in your GameRules. The return value says whether the function handles this item, while the "pick up or not" decision goes through the out parameter bAllowPickup (a byte: 0 or 1). Here only Sharpshooters of level 6 and up can pick up the M99:

function bool OverridePickupQuery(Pawn Other, Pickup item, out byte bAllowPickup)
{
 local KFPlayerReplicationInfo KFPRI;
 local Name VeterancyName;
 local int VeterancyLevel;
 if(Other!=none && Other.PlayerReplicationInfo!=none)
 KFPRI=KFPlayerReplicationInfo(Other.PlayerReplicationInfo);
 if(KFPRI!=none)
 {
 VeterancyName=KFPRI.ClientVeteranSkill.Name;
 VeterancyLevel=KFPRI.ClientVeteranSkillLevel;
 }
 if(item.IsA('M99Pickup') && (VeterancyName!='SRVetSharpshooter' || VeterancyLevel<6))
 {
 bAllowPickup=false;
 return true;
 }
 if ( (NextGameRules != None) && NextGameRules.OverridePickupQuery(Other, item, bAllowPickup) )
 return true;
 return false;
}

Damage types and perks (НАТАХА's article "Типы урона (DamageTypes)"). All damage in KF derives from KFMod.KFWeaponDamageType (the chain is Engine.DamageType → UnrealGame.WeaponDamageType → KFMod.KFWeaponDamageType), and damage type variables decide which perk the damage belongs to:

PerkVariableStat
BerserkerbIsMeleeDamage (damage type DamTypeMelee)KFStatsAndAchievements.AddMeleeDamage
FirebugbDealBurningDamageKFStatsAndAchievements.AddFlameThrowerDamage
DemolitionsbIsExplosiveKFStatsAndAchievements.AddExplosivesDamage

Fire damage consists of the initial damage plus the damage from burning afterwards: FlareRevolver → DamTypeFlareRevolver + DamTypeFlareRevolver, MAC10 → DamTypeMAC10MPInc + DamTypeMAC10MPInc, Trenchgun → DamTypeTrenchgun + DamTypeTrenchgun, FlameThrower → DamTypeBurned + DamTypeFlamethrower, HuskGun → DamTypeHuskGun + DamTypeFlamethrower, FlameNade → DamTypeFlamethrower + DamTypeFlamethrower. When giving a zed fire resistance, account for both types. DamTypeTrenchgun has both bDealBurningDamage and bIsPowerWeapon, so it levels both Support and Firebug; DamTypeHuskGunProjectileImpact and DamTypeFlareProjectileImpact do not set targets on fire but still count for levelling. Direct-hit damage from Demolitions projectiles derives from DamTypeRocketImpact, which derives from DamTypeKFSnipe, so such hits level Sharpshooter rather than Demolitions.

1.5 times stronger armour for VIPs (Team 2/5). Instead of 150 armour points it is simpler to make normal armour 1.5 times stronger with a modified GetBodyArmorDamageModifier in every SRVet* class (or in SRVeterancyTypes), checking for a VipItem in the inventory with a helper HasItem (kept in an abstract Utilities class):

static function bool HasItem(Pawn P, Name ItemName)
{
 local Inventory Inv;
 for(Inv=P.Inventory;Inv!=None;Inv=Inv.Inventory)
 {
 if(Inv.Name==ItemName)
 return true;
 }
 return false;
}
static function float GetBodyArmorDamageModifier(KFPlayerReplicationInfo KFPRI)
{
 if(class'Utilities'.static.HasItem(PlayerController(KFPRI.Owner).Pawn,'VipItem'))
 return 0.66;
 return 1.00;
}

According to Essence, the original article's KFPRI.Owner.Pawn does not compile: the PlayerController(KFPRI.Owner) cast is required.