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

New forum ✌️

Heya!

it’s been a while, but I’ve recently found the time to write a new forum. It’s no longer bbpress, but a self written solution. Downside: no deep integration into wordpress. Upside: no more spammers and deep integration into the game!

What does this mean? Everybody is allowed to read. Only players of the game are allowed to write! You can login with your IGN and savecode.

Hope to see you at the new forum!

PS: This game is not dead yet. I’m currently working on a complete rewrite with proper multiplayer support.

Cheers
Andy

Forum closed to readonly mode

Due to heavy spammer attacks the forum is currently in readonly mode.

I’ve cleaned the database from all 30.000 spam posts (why do people post this disgusting stuff?).

Currently I’m looking for ways to prevent further spam in the forum. Akismet Anti-Spam doesn’t seem to be enough anymore.

Version 1.4.1

A small maintenance release was pushed to Google Play and the iOS App Store:

  • Upgrade to the latest SDKs
  • Fix a bonus round exploit
  • Fix crash in certain situations, by preventing Knusperhexe from eating revive mass creeps

Christmas Present

This weekend, a special present will wait for you at the black market! I’ve heard it’s a pretty bad-ass sword.

What might it be?

What might it be?

Wish you all a happy and peaceful Christmas time!

PS: Just noticed this morning that 59 players already purchased a black market item for 250 this night. You’ve got your 250 relics back and will get a present, too.

Version 1.4 released on iOS

Version 1.4 is finally released on iOS! Sorry for that loooong delay… First there was a certificate issue, then I couldn’t build the iOS version on windows anymore and then I had a lot to do for a little side project. But what lasts long… 🙂

Have fun!

Release notes 1.4

Done! The latest version is pushed to Google Play!

Don’t miss the new stuff that comes with this version:

New creep ability: Revive

Creeps learned the power to revive themselves. Watch out for creeps with Revive. If you kill one, it has the chance to revive itself with reduced healthpoints!
mazebert-screenshot

Forge golden cards without RNG

A powerful (yet expensive) card smith entered the game: Reginn the Dvergr!
You find him by navigating to my wizard -> cards and select a card of which you do not own the golden version.
Behold: Reginn needs to be unlocked first by beating golden grounds.
reginn_option_my_cardsreginn_buy

New black market item: Hydra Arrow

TheMarine’s suggestion finally made it to the game. As a new item on Black Market rotation!
Item-64-card

Improvements / Balancing

  • Removed Unlucky Pants, Skull of Darkness, Spectral Daggers and Cape of the Spectre from Black Market (they are now regular expert cards)
  • Slight performance enhancement using new AS3 array/vector methods
  • Holgar‘s mead drop chance is now based on luck, not item chance
  • Stonecutter’s Temple got buffed (+3% damage per level instead of +1% damage per level)
  • Scepter of Time player skill now only needs one point to invest

Bugfixes

  • Marriage status of wedding rings is now persisted. In general the whole marriage procedure is now displayed more explicit (with a countdown)!
  • When the savegame is manipulated the game cannot advance anymore until the manipulation is undone
  • Fixed a potential game freeze after bonus round
  • Scarecrow‘s first shot is no longer broken
  • Fixed the bug that stole you an inventory slot under certain conditions

Have fun!!!