Creating a Starcraft AI – Part 31: Hurter faster

(This is an ongoing series, and I reference previous episodes. If you’re new, I suggest you start at the first post)

First part of this series | Previous | Index

I did a little testing, and the shield regeneration basically negates the effect of the leak-through. It was around 2-3 points of damage in the worst cases. Still, for completeness sake, let’s apply the shield regeneration anyway. This is more of an exercise in futility.

According to this link (which I already mentioned before), protoss shields regenerate 7/256 points per frame (HP and shields are stored to 1/256 precision). We know how much damage we deal, and how often. Damage dealt is always an integer(?). During the leak through, we deal half points of shield damage, so 128/256.

So every x frame, we deduct 128/256, and every frame we add another 7/256 . This is… basically the FizzBuzz problem. I knew it would be useful one day!

There is also the edge-edge case where dmatrixed units starts with 0 shields when we begin the attack, and was plagued before, so it has 1 HP. Very likely, but let’s go full retard model that anyway. But even with all of this, this is not entirely accurate, since we don’t know the starting fractional value of the target unit’s hp/shields. Oh, well.

I use double values for keeping track of unit hp/shield values. How good is that for calculating base 2 values? Better than doing the same thing in decimal, as it turns out – they are base 2 numbers in disguise. You should never use floating point types to represent currency, or something similar. (Use BigDecimal instead)

So instead of simple division, we need to use something different. A familiar tool. A ghost from the past.

That’s right.

Képtalálat a következőre: „lööp memes”

Lööps.

A simple frame counter, actually. We begin to simulate the hurt, and terminate when the target unit’s hp is 0 or less. It is actually a bit-shifted fixed point integer, so it seems that the unit actually needs to reach zero HP to die. Here is the relevant OpenBW code line. (Thanks Bytekeeper!)

With all this in mind, here is the full calculating logic. The frames are counted from the first attack, and the loop goes until the enemy unit’s health reaches zero.

   public static int calculateFramesNeededToKill(Unit attackingUnit, Unit targetUnit) {
        int unitDamage = Main.unitStatCalculator.damage(attackingUnit.getGroundWeapon().type());

        double dmatrixPoints = targetUnit.getDefenseMatrixPoints();
        double targetHP = targetUnit.getHitPoints();
        double targetShields = targetUnit.getShields();
        int targetMaxShields = targetUnit.getType().maxShields();
        int targetMaxHP = targetUnit.getType().maxHitPoints();

        double shieldRegenRate = 7d/  256d;
        double zergHPRegenRate = 4d/  256d;

        int unitAttackSpeed =  Main.unitStatCalculator.groundWeaponDamageCooldown(attackingUnit);

        int framecounter = 0;
        while (targetHP > 0) {
            int targetArmor = Main.unitStatCalculator.armor(targetUnit.getType());
            double remainingDamage = 0;
            double leakThrough = 0;

            if (framecounter == 0 || framecounter % unitAttackSpeed == 0) {
                remainingDamage = unitDamage;
            }

            if (dmatrixPoints > 0) {
                if (dmatrixPoints < unitDamage) {
                    remainingDamage = unitDamage-dmatrixPoints;
                    dmatrixPoints = 0;
                }
                leakThrough = 0.5d;
            }

            int shieldsLevel = targetUnit.getPlayer().getUpgradeLevel(UpgradeType.Protoss_Plasma_Shields);
            if (targetShields > 0 && remainingDamage > 0) {
                double effectiveShieldDamage = (remainingDamage-shieldsLevel);
                if (targetShields < effectiveShieldDamage) {
                    remainingDamage = remainingDamage - targetShields;
                    targetShields=0;

                } else {
                targetShields = targetShields - (remainingDamage-shieldsLevel) - leakThrough;
                leakThrough = 0;

            }
            //Apply shield regeneration
            targetShields = targetShields + shieldRegenRate;

            //Can't regenerate over the original maximum
            if (targetShields > targetMaxShields) {
                targetShields = targetMaxShields;
            }
            if (remainingDamage > 0) {
                double effectiveHPDamage = remainingDamage - targetArmor;
                targetHP = targetHP - effectiveHPDamage - leakThrough;
            }

            //Apply regeneration, if the unit isn't dead
            if (targetHP > 0 && targetUnit.getType().getRace() == Race.Zerg) {
                targetHP = targetHP + zergHPRegenRate;
            }

            //Can't regenerate over max p
            if (targetHP > targetMaxHP) {
                targetHP = targetMaxHP;
            }

            framecounter++;
        }
        return framecounter;
    }

A couple things to explain here. As you can see, I get the upgrade levels every frame – an upgrade can be completed during the attack process, and it can have a significant impact – just think about the Ultralisk armor upgrade! I also used the unitStatCalculator a few times – all that does is adds up the base armor, and upgrades. Same thing with attack speed.

I forgot to add two rules, one for damage type – the target unit’s size, and the damage type won’t change, so I only need to calculate the damage multiplier once.

        double damageMultiplier = 1;
        if (attackingWeapon.type().damageType().equals(DamageType.Concussive)) {
            if (targetUnit.getType().size().equals(UnitSizeType.Small)) {
                damageMultiplier = 0.25d;
            } else if (targetUnit.getType().size().equals(UnitSizeType.Medium)) {
                damageMultiplier = 0.5d;
            }
        } else if (attackingWeapon.type().damageType().equals(DamageType.Explosive)) {
            if (targetUnit.getType().size().equals(UnitSizeType.Small)) {
                damageMultiplier = 0.5d;
            } else if (targetUnit.getType().size().equals(UnitSizeType.Medium)) {
                damageMultiplier = 0.75d;
            }
        }
//Later
            if (framecounter == 0 || framecounter % unitAttackSpeed == 0) {
                remainingDamage = unitDamage*damageMultiplier;
            }

One might ask, why am I not using switch statements. The answer is that’s just how I roll, madafaka I consider them a relic of the past, which they totally are.

The second consideration is the damageFactor, which is the number of missiles/projectiles/etc. The damage values are per projectile, and armor/shield level gets deduced per projectile as well. It’s relatively rare that when using multi-factor attacks, not all attacks hit. I can think of two examples: Goliaths firing at interceptors, and getting only one missile in before the interceptor returns, and maybe firebats (I have to check that. Graphics is misleading with firebats. This is one of the things I like very much in the remastered version, the two flame cones are more separated visually). This required more code changes than I first anticipated, so here is the full method (well, almost all of it) again, because why not.

        while (targetHP > 0) {
            int targetArmor = Main.unitStatCalculator.armor(targetUnit.getType());
            double remainingDamage = 0; //Always a per-projectile value!
            double leakThrough = 0;
            if (framecounter == 0 || framecounter % unitAttackSpeed == 0) {
                remainingDamage = unitDamage*damageMultiplier;
            }
            if (dmatrixPoints > 0) {
                if (dmatrixPoints < unitDamage) {
                    remainingDamage = ((unitDamage*damageFactor)-dmatrixPoints)/damageFactor;
                    dmatrixPoints = 0;
                }
                leakThrough = 0.5d;
            }
            int shieldsLevel = targetUnit.getPlayer().getUpgradeLevel(UpgradeType.Protoss_Plasma_Shields);
            if (targetShields > 0 && remainingDamage > 0) {
                double effectiveShieldDamage = (remainingDamage-shieldsLevel)*damageFactor;
                if (targetShields < effectiveShieldDamage) {
                    remainingDamage = (effectiveShieldDamage - targetShields)/damageFactor;
                    targetShields=0;

                } else {
                    targetShields = targetShields - ((remainingDamage - shieldsLevel)*damageFactor) - leakThrough;
                    leakThrough = 0;
                }
            }
            //Apply shield regeneration
            targetShields = targetShields + shieldRegenRate;

            //Can't regenerate over the original maximum
            if (targetShields > targetMaxShields) {
                targetShields = targetMaxShields;
            }
            if (remainingDamage > 0) {
                double effectiveHPDamage = (remainingDamage - targetArmor)*damageFactor;
                targetHP = targetHP - effectiveHPDamage - leakThrough;
            }
            //Apply regeneration, if the unit isn't dead
            if (targetHP > 0 && targetUnit.getType().getRace() == Race.Zerg) {
                targetHP = targetHP + zergHPRegenRate;
            }
            //Can't regenerate over max p
            if (targetHP > targetMaxHP) {
                targetHP = targetMaxHP;
            }
            framecounter++;
        }
        return framecounter;
    }

I corrected the method also to distinguish between ground/air units, and if the attacking unit can’t hurt the enemy, it will return -1. (It shouldn’t be called in those cases in the first place, optimally)

        if (targetUnit.getType().isFlyer()) {
            attackingWeapon = attackingUnit.getAirWeapon();
        } else {
            attackingWeapon = attackingUnit.getGroundWeapon();
        }

        if (attackingWeapon == null) {
            return -1;
        }

I actually like this calculation a bit better. It seems more precise, somehow. Its like, whoa, man, you know?

If I can fit in dog memes, I WILL fit in dog memes.

The other major deciding factor in target importance evaluation is distance – which, if I have a path, can be converted into frames, since I know the unit’s speed. I don’t need to use too precise pathfinding there either (although ground distance can differ vastly from air distance, I can get away with guesses here. This is about magnitudes, not exact values)

I also extracted this method to the new CombatSimulator class. Testing this is a little more complicated. The given BWAPI classes does not use proper setters, and/or constructors – they are meant to represent in-game entities, without the player able to modify their attributes (Rightfully so). But I’d really like to test this out, and running a game just for this is too much. So I need to mock these values, using Mockito. That does the sneaky, and mocks the return values of stuff. Good enough. (I should be hired as a specification writer and chief explainer of things)

Be aware of Mockito’s dependencies. I got nonsensical errors a few times about it. Since BWAPI objects are not really meant to be instantiated by anyone but BWAPI itself, I need to mock the everything here – that seems a little long, but not particularly hard task.

Long story short, it’s not really feasible here. Mockito assumes you have full control over your classes, and for example, can’t mock final classes. It is an understandable design decision from their part, but it’s a major pain point here.

But there is an answer. Powermock supposedly does what I need here. It builds upon the Mockito framework.

Disclaimer: Writing tests is not my favorite part. I’m not arguing over their importance, however. What is even less my favorite is getting entangled in dependency hell. Powermock extends Mockito, but you have to pick the right version combo, otherwise you get NoSuchMethod and ClassNotFoundExceptions. Also, Mockito 2.0+ supposedly supports final class mocking. I didn’t get it to work, but I did it with Powermock, so let’s just move on. I suggest downloading the whole pack, with dependencies from the Powermock site (that contains the Mockito version as well). Also, mockito-core needs it’s own dependencies as well (byte-buddy, and objenesis libraries), as I mentioned before. At this point it was running up until this point:

@RunWith(PowerMockRunner.class)
@PrepareForTest({WeaponType.class, Main.class, UnitStatCalculator.class})

public class CombatSimulatorTests {

    @Test
    public void marineVsZergling () throws Exception {
        Unit zergling = mock(Unit.class);
        Unit marine = mock(Unit.class);
        Weapon marineWeapon = mock(Weapon.class);


        given(marine.getHitPoints()).willReturn(40);
        given(marine.getType()).willReturn(UnitType.Terran_Marine);
        WeaponType grifle = PowerMockito.mock(WeaponType.class);
        given(marineWeapon.type()).willReturn(grifle);
        PowerMockito.when(grifle.damageType()).thenReturn(DamageType.Normal);


        UnitStatCalculator mockulator = mock(UnitStatCalculator.class);
        PowerMockito.mockStatic(Main.class);

With that detour, I was able to mock final methods at last. Insert clever wordplay here, I’m tired. The problem was now the mocking of static methods, most importantly, the UnitStatCalculator. This also needed some tinkering about. This detour made me a little bit weary at this point – the linked solution might not be the answer you seek.

It seems doable, but let me end this article with a cliffhanger – will the static mock man triumph over the JVM? Find out in our next episode!

Thanks for reading! If you liked this article, you can subscribe to my mailing list (in the box on the right), follow me on Twitter, or Facebook, or check the RSS feed on the top right. You can also find a shop with SC-related (and other) goodies there.

Also, I’d like to share an affiliate link with you – I’m not directly sponsored by Hired.com, but I found their services useful. It costs you nothing to check them out, but you can support me this way. They are basically a very good unified job search platform, where you apply once (with salary request, and conditions), then employers can make offers. It is hidden from your current employer, absolutely free to use and if you get hired, you even get a (not insignificant) cash bonus. I encourage you to check them out. Here is the link.

Leave a Reply