Creating a Starcraft AI – Part 32: Test with the best

(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

Recently I’ve been occupied with the SCHNAIL project (Starcraft Human ‘N’ AI League), so I lapsed a little with the updates. Don’t worry, JumpyDoggoBot is alive and kicking, and I fully intend to continue this series as well. But can you blame me?

monch monch monch!

So previously, I started to write a proper unit test for my fledgling combat simulator. This proved to be almost as challenging as writing the combat simulator itself. Mocking static, and/or final classes is not really something that Mockito does, so I had to use PowerMockito to achieve my goals. This needs some setup. First, give an annotation in the start of the class, for every final class:

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

Then, not forgetting to use PowerMock for the actual classes.

WeaponType grifleType = PowerMockito.mock(WeaponType.class);
given(marineWeapon.type()).willReturn(grifleType);
PowerMockito.when(grifleType.damageType()).thenReturn(DamageType.Normal);

And doing it everywhere, figuring out the mocking…

    @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 grifleType = PowerMockito.mock(WeaponType.class);
        given(marineWeapon.type()).willReturn(grifleType);
        PowerMockito.when(grifleType.damageType()).thenReturn(DamageType.Normal);


        Weapon grifle = mock(Weapon.class);
        given(grifle.type()).willReturn(grifleType);
        given(grifleType.damageFactor()).willReturn(1);
        given(marine.getGroundWeapon()).willReturn(grifle);

        PowerMockito.mockStatic(Main.class);
        PowerMockito.mockStatic(UnitStatCalculator.class);
        UnitStatCalculator mockulator = mock(UnitStatCalculator.class);
        PowerMockito.whenNew(UnitStatCalculator.class).withAnyArguments().thenReturn(mockulator);
        given(Main.getUnitStatCalculator()).willReturn(mockulator);


        given(mockulator.damage(grifleType)).willReturn(6);
        given(mockulator.groundWeaponDamageMaxCooldown(UnitType.Terran_Marine)).willReturn(15);
        Player enemy = mock(Player.class);
        UnitType zerglingType = PowerMockito.mock(UnitType.class);
        
        given(enemy.getUpgradeLevel(UpgradeType.Protoss_Plasma_Shields)).willReturn(0);
        given(zergling.getPlayer()).willReturn(enemy);

        given(zergling.getShields()).willReturn(0);
        given(zergling.getHitPoints()).willReturn(35);
        given(zergling.getType()).willReturn(zerglingType);
        
        PowerMockito.when(zerglingType.maxHitPoints()).thenReturn(35);

        int i = CombatSimulator.calculateFramesNeededToKill(marine, zergling);

        System.out.println(i);
    }

Thanks to testing, I spotted a flaw in my code.

//Instead of:
  int unitAttackSpeed =  Main.getUnitStatCalculator().groundWeaponDamageCooldown(attackingUnit);
//I shoud use this:
int unitAttackSpeed =  Main.getUnitStatCalculator().groundWeaponDamageMaxCooldown(attackingUnit.getType());

I’m calculating a hypothetical scenario, so the actual cooldown is meaningless here. I ran the test again, and I got 76 frames as a result. The cooldown is 15 frames, and 35 HP is 6 shots for a marine – this seems correct, which is great news. Well, there is 1 frame off in the end, but that’s a trivial detail.

Now, this is a simple scenario – literally the simplest I could think of, so we need to spice it up a bit.

Képtalálat a következőre: „it's time to oil up”
alternatively

A couple thoughts and observations at this point:

  • Even though this is a test, we are not actually asserting/expecting any value at the moment. I’m aware of this. When I’m reasonably certain that everything works as intended, I’ll maybe add some test cases. Although at that point, I’m probably finished with the simulator anyway, and won’t change it in the future.
  • Attack speed upgrades, and stim packs exist.
  • Even though some values might change, most damage and (base) armor values are fixed, so I might just as well have some collection to store them. They are not hardcoded in BWAPI I believe, because technically, you can play “Use Map Settings” maps as well, where the values can be whatever.

Regarding attack speeds, there is a Liquipedia page for it – there are exactly 3 scenarios, when I need to consider this: Stimmed marines, stimmed firebats, and zerglings with adrenal glands. What is interesting, stimmed marines seem to have 7.5 frames of cooldown, which is weird – frames are distinct units of time, so the unit does damage at frame 7, or frame 8, not between them. I didn’t investigate further, but my guess is frame 8. Most likely, it’s a condition that if the cooldown reaches 0, then the unit can attack, so I’m gonna be working from that assumption.

The JBWAPI UnitStatCalculator already takes the Adrenal Glands upgrades into consideration, but not the stim packs. Stim packs are a limited-time effect, and can run out during the simulation. The way it works, it sets the max cooldown to 7.5/11 frames, while it’s active. That means, that I only need to check if it ran out, when I’m resetting the countdown – which would be very simple, but I’m doing this the other way around, checking if the frame count is the right amount.

Since this is a limited-time effect, let’s have that as a parameter. In the combat sim, I’ll give an argument called stimFrames, which means “that many frames remain from the go juice”.

Now, giving a correct value to this is another matter entirely, as it is hard to predict in the future. However, that is a functionality that can be decoupled.

First of all, a change in the method signature.

    public static int calculateFramesNeededToKill(Unit attackingUnit, Unit targetUnit) {
        return calculateFramesNeededToKill(attackingUnit, targetUnit, 0);
    }
//aw yis, so much convenience
    public static int calculateFramesNeededToKill(Unit attackingUnit, Unit targetUnit, int stimFrames) {

Then, change the calculation to handle fractions for attacking frames. First, the attack speed needs to be a double, and calculated differently for the two edge cases. The unit type double-check is needed, because we might erroneously give a greater than 0 stimFrames value to the method for other unit types.

        if (stimFrames >0 && (attackingUnit.getType().equals(UnitType.Terran_Marine) || attackingUnit.getType().equals(UnitType.Terran_Firebat))) {
            if (attackingUnit.getType().equals(UnitType.Terran_Marine)) {
                unitAttackSpeed = 7.5;
            } else if (attackingUnit.getType().equals(UnitType.Terran_Firebat)) {
                unitAttackSpeed = 11;
            }
        } else {
            unitAttackSpeed = Main.getUnitStatCalculator().groundWeaponDamageMaxCooldown(attackingUnit.getType());
        }
        int framecounter = 0;
        while (targetHP > 0) {
            if (framecounter > stimFrames) {
                unitAttackSpeed = Main.getUnitStatCalculator().groundWeaponDamageMaxCooldown(attackingUnit.getType());
            }

With the marine vs. zergling scenario, I got 39 frames in return – which is, I think, correct. 30+7.5 +1 off frame means six attacks (remember, the first attack happens at frame 0), so I deem this solution correct.

Update:According to tscmoo, the cooldown is rounded down. He is a greater authority on this than Liquipedia, so I modified the value to 7 from 7.5. Everything else is left as is, because it’s not affecting anything, and the thought process was actually interesting here.

I’ve run some more tests – my dmatrix damage calculation was also incorrect (I calculated the damage every frame, not just in the right ones) .

Képtalálat a következőre: „lab coat guy”
I’ve run the tests. The results are: I have no idea what I’m doing.

The test codes are very straightforward, and frankly, uninteresting, so I won’t copy those here. I will share the completed combat simulator code, however.

    public static int calculateFramesNeededToKill(Unit attackingUnit, Unit targetUnit, int stimFrames) {
        Weapon attackingWeapon;
        if (targetUnit.getType().isFlyer()) {
            attackingWeapon = attackingUnit.getAirWeapon();
        } else {
            attackingWeapon = attackingUnit.getGroundWeapon();
        }

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


        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;
            }
        }

        int damageFactor = attackingWeapon.type().damageFactor();
        int unitDamage = Main.getUnitStatCalculator().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;

        double unitAttackSpeed = 0;
        
        if (stimFrames >0 && (attackingUnit.getType().equals(UnitType.Terran_Marine) || attackingUnit.getType().equals(UnitType.Terran_Firebat))) {
            if (attackingUnit.getType().equals(UnitType.Terran_Marine)) {
                unitAttackSpeed = 7;
            } else if (attackingUnit.getType().equals(UnitType.Terran_Firebat)) {
                unitAttackSpeed = 11;
            }
        } else {
            unitAttackSpeed = Main.getUnitStatCalculator().groundWeaponDamageMaxCooldown(attackingUnit.getType());
        }
        int framecounter = 0;
        while (targetHP > 0) {
            if (framecounter > stimFrames) {
                unitAttackSpeed = Main.getUnitStatCalculator().groundWeaponDamageMaxCooldown(attackingUnit.getType());
            }

            int targetArmor = Main.getUnitStatCalculator().armor(targetUnit.getType());
            double remainingDamage = 0; //Always a per-projectile value!
            double leakThrough = 0;
            if (framecounter == 0 || framecounter % unitAttackSpeed < 1) {
                remainingDamage = unitDamage*damageMultiplier;
            }
            if (dmatrixPoints > 0 && remainingDamage > 0) {
                if (dmatrixPoints < unitDamage) {
                    remainingDamage = ((unitDamage*damageFactor)-dmatrixPoints)/damageFactor;
                    dmatrixPoints = 0;
                } else {
                    dmatrixPoints = dmatrixPoints - (unitDamage*damageFactor);
                    remainingDamage = 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;
                    remainingDamage = 0;
                }
            }
            //Apply shield regeneration
            targetShields = targetShields + shieldRegenRate;

            //Can't regenerate over the original maximum
            if (targetShields > targetMaxShields) {
                targetShields = targetMaxShields;
            }

            double effectiveHPDamage;
            if (remainingDamage > 0) {
                effectiveHPDamage = (remainingDamage - targetArmor)*damageFactor;

            } else {
                effectiveHPDamage = 0;
            }
            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 want to call it the final version, but it’s more like the csim_12_fuck_final_V3(2).docx of my life at this point.

Just by derping around a bit with unit tests, I have managed to discover 3 bugs, and probably saved a lot of time for myself in the future. Now let’s circle back to the original purpose of this method, which is to give an estimation to calculate target importance.

This endeavor has given me some related ideas – extensive bot testing is something I want to implement in SCHNAIL too. Of course, not initially, the existing bots need to adapt to the new requirements. But that is an entirely different topic, and I will write about it in the next SCHNAIL update post.

I will put all of the rest together in the next post – at this point, I’m considering some kind of mind map/graph for my to-dos on JumpyDoggoBot. This is a labor of love, so there is no rush, or schedule to complete, therefore no real sense in using project management tools and methodology. Also, whenever you install JIRA, a kitten dies.

Thanks for reading! If you want to get notified of updates, please consider subscribing to the mailing list!

Leave a Reply