Unit testing game logic

I'm currently rewriting Mazebert in Java with the goal to add multiplayer. In order to do this, I need to decouple all game logic code from client code. The game logic is then a separate module and runs a deterministic simulation in its own thread. While being quite the opposite of what game programming was for me all these years - stuffing everything into the scene graph of the used rendering framework - it brings a lot of opportunities in terms of programming. One of them is the ability to easily unit test all game logic. When porting the nature towers of the game, I've written unit tests for almost every tower, and it's been super fun. I was even able to understand and fix some problems of the old game:
  • Frog poison was doing less than the promised 100% DoT
  • Kills from frog DoT did not drop cards
  • Huli's aura says all femal towers in range, but did not scale with range changing items
  • Balu's cuddle did not scale with attack speed modifier
For example, here's the unit test code for Holgar the Horrible:
class VikingTest extends SimTest {
    RandomPluginTrainer randomPluginTrainer = new RandomPluginTrainer();
    DamageSystemTrainer damageSystemTrainer;

    Wizard wizard;
    Viking viking;
    Creep creep;

    @BeforeEach
    void setUp() {
        simulationListeners = new SimulationListeners();
        randomPlugin = randomPluginTrainer;
        unitGateway = new UnitGateway();
        projectileGateway = new ProjectileGateway();
        damageSystem = damageSystemTrainer = new DamageSystemTrainer(simulationListeners);
        lootSystem = new LootSystemTrainer();

        commandExecutor = new CommandExecutor();
        commandExecutor.init();

        wizard = new Wizard();
        unitGateway.addUnit(wizard);

        viking = new Viking();
        viking.setWizard(wizard);
        unitGateway.addUnit(viking);

        creep = new Creep();
        unitGateway.addUnit(creep);
    }

    @Test
    void attack_canHitSameCreepWithTwoAxesAtOnce() {
        whenVikingAttacks();

        assertThat(creep.getHealth()).isEqualTo(80);
    }

    @Test
    void attack_canHitTwoCreeps() {
        Creep anotherCreep = new Creep();
        unitGateway.addUnit(anotherCreep);

        whenVikingAttacks();

        assertThat(creep.getHealth()).isEqualTo(90);
        assertThat(anotherCreep.getHealth()).isEqualTo(90);
    }

    @Test
    void kill_canDropMead_drop() {
        randomPluginTrainer.givenFloatAbs(0.0f);
        damageSystemTrainer.givenConstantDamage(1000);

        whenVikingAttacks();

        assertThat(wizard.potionStash.get(PotionType.Mead).getAmount()).isEqualTo(1);
    }

    @Test
    void kill_canDropMead_noDrop() {
        randomPluginTrainer.givenFloatAbs(0.9f);
        damageSystemTrainer.givenConstantDamage(1000);

        whenVikingAttacks();

        assertThat(wizard.potionStash.get(PotionType.Mead)).isNull();
    }

    @Test
    void kill_canDropMead_badDropChanceOfCreep_noDrop() {
        randomPluginTrainer.givenFloatAbs(0.0f);
        damageSystemTrainer.givenConstantDamage(1000);
        creep.setDropChance(0);

        whenVikingAttacks();

        assertThat(wizard.potionStash.get(PotionType.Mead)).isNull();
    }

    @Test
    void drinksMead() {
        givenMeadPotionIsDrank();
        givenMeadPotionIsDrank();
        givenMeadPotionIsDrank();

        CustomTowerBonus bonus = new CustomTowerBonus();
        viking.populateCustomTowerBonus(bonus);
        assertThat(bonus.title).isEqualTo("Mead:");
        assertThat(bonus.value).isEqualTo("3");
    }

    @Test
    void drinksNoMead() {
        CustomTowerBonus bonus = new CustomTowerBonus();
        viking.populateCustomTowerBonus(bonus);
        assertThat(bonus.title).isEqualTo("Mead:");
        assertThat(bonus.value).isEqualTo("0");
    }

    @Test
    void drinksMead_hasEffects() {
        givenMeadPotionIsDrank();
        assertThat(viking.getChanceToMiss()).isEqualTo(0.01f);
        givenMeadPotionIsDrank();
        assertThat(viking.getChanceToMiss()).isEqualTo(0.02f);
    }

    private void givenMeadPotionIsDrank() {
        wizard.potionStash.add(PotionType.Mead);
        DrinkPotionCommand command = new DrinkPotionCommand();
        command.potionType = PotionType.Mead;
        command.playerId = wizard.getPlayerId();
        commandExecutor.executeVoid(command);
    }

    private void whenVikingAttacks() {
        viking.simulate(3.0f); // attack
        projectileGateway.simulate(1.0f); // projectile spawning
        projectileGateway.simulate(1.0f); // projectile hitting
    }
}
And here is the production code of the tower:
public strictfp class Viking extends Tower {

    public Viking() {
        setBaseCooldown(3.0f);
        setBaseRange(2.0f);
        setAttackType(AttackType.Fal);
        setStrength(0.89f);
        setDamageSpread(0.16f);
        setGender(Gender.Male);
        setElement(Element.Nature);

        addAbility(new ProjectileDamageAbility(ProjectileViewType.Axe, 6));
        addAbility(new VikingMultishot());
        addAbility(new VikingMead());
    }

    @Override
    protected float getGoldCostFactor() {
        return 1.1f;
    }

    @Override
    public String getName() {
        return "Holgar the Horrible";
    }

    @Override
    public String getDescription() {
        return "Holgar is the terror of the seven seas with a slight alcohol problem.";
    }

    @Override
    public String getAuthor() {
        return "Holger Herbricht";
    }

    @Override
    public Rarity getRarity() {
        return Rarity.Rare;
    }

    @Override
    public int getItemLevel() {
        return 50;
    }

    @Override
    public String getSinceVersion() {
        return "0.3";
    }

    @Override
    public String getModelId() {
        return "viking";
    }

    @Override
    public int getImageOffsetOnCardY() {
        return 8;
    }

    @Override
    public void populateCustomTowerBonus(CustomTowerBonus bonus) {
        bonus.title = "Mead:";
        bonus.value = "" + getConsumedMeadCount();
    }

    private int getConsumedMeadCount() {
        MeadAbility meadPotionAbility = getAbility(MeadAbility.class);
        if (meadPotionAbility == null) {
           return 0;
        } else {
            return meadPotionAbility.getStackCount();
        }
    }
}
With the ability to attack two targets:
public strictfp class VikingMultishot extends AttackAbility {

    public VikingMultishot() {
        super(2, true);
    }

    @Override
    public boolean isVisibleToUser() {
        return true;
    }

    @Override
    public String getTitle() {
        return "Double Throw";
    }

    @Override
    public String getDescription() {
        return "Holgar throws two axes at once. He likes the sound of two cracking skulls.";
    }

    @Override
    public String getIconFile() {
        return "0026_axeattack2_512";
    }
}
And the ability to find mead:
public strictfp class VikingMead extends Ability<Tower> implements OnKillListener {

    private static final float CHANCE = 0.01f;

    private final LootSystem lootSystem = Sim.context().lootSystem;

    @Override
    protected void initialize(Tower unit) {
        super.initialize(unit);
        unit.onKill.add(this);
    }

    @Override
    protected void dispose(Tower unit) {
        unit.onKill.remove(this);
        super.dispose(unit);
    }

    @Override
    public void onKill(Creep target) {
        if (getUnit().isAbilityTriggered(CHANCE * StrictMath.min(1, target.getDropChance()))) {
            Wizard wizard = getUnit().getWizard();
            lootSystem.dropCard(wizard, target, wizard.potionStash, PotionType.Mead);
        }
    }

    @Override
    public boolean isVisibleToUser() {
        return true;
    }

    @Override
    public String getTitle() {
        return "Drink for Victory";
    }

    @Override
    public String getDescription() {
        return "Whenever Holgar kills a creep, there is a " + format.percent(CHANCE) + "% chance he finds a bottle of mead.";
    }

    @Override
    public String getIconFile() {
        return "9005_MeadPotion";
    }
}

this is fantastic to hear!

Interesting - I never dipped into game development code before. The unit tests I wrote up to now all involved checking on data objects from databases.