Introduction

We’ve finished our Hero model and tested it. Before we go further, we need to add more models to our game.

Model Overview

We need to add one more field to our Hero model:

  • experience: amount of experience Hero has. A new Hero starts with 0 experience.

To be able to play, we need a Location to send our hero to. The Location should have:

  • unique identifier: UUID
  • name: non-empty, alphanumeric string

When we visit a Location, we should be able to spot a Creature. The Creature should have:

  • unique identifier: UUID
  • name: non-empty, alphanumeric string
  • experience reward: amount of experience we get when we defeat it, greater than 0

When we defeat a Creature, we should be able to loot an Item from it! The Item should have:

  • unique identifier: UUID
  • name: non-empty, alphanumeric string
  • price: greater than 0

Challenge

Based on the previous post, I encourage you to try adding the missing Experience field to Hero and implement the Location, Creature, and Item models on your own. You can find all the requirements in the section above.

If you need help, you can find my solution below.

Hero Model

src/main/scala/hero/model/HeroModel.scala

package dev.kamgy
package hero.model

import java.util.UUID

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.collection.{MaxLength, MinLength}
import io.github.iltotore.iron.constraint.numeric.{Positive, Positive0}
import io.github.iltotore.iron.constraint.string.Alphanumeric

object HeroModel {
  type HeroId = HeroId.T

  object HeroId extends RefinedType[UUID, Pure] {
    def generate(): HeroId = HeroId.assume(UUID.randomUUID())
  }

  type HeroName = HeroName.T
  object HeroName extends RefinedType[String, Alphanumeric & MinLength[5] & MaxLength[32]]

  type HeroLevel = HeroLevel.T
  object HeroLevel extends RefinedType[Int, Positive]

  type HeroExperience = HeroExperience.T
  object HeroExperience extends RefinedType[Int, Positive0]
}

Notice, that for the HeroExperience I used Positive0. This means that the value can be 0 or greater. This is important, because when we create a new Hero, it should start with 0 experience.

src/main/scala/hero/model/Hero.scala:

package dev.kamgy
package hero.model

import hero.model.HeroModel.*

case class Hero(
  id: HeroId,
  name: HeroName,
  level: HeroLevel,
  experience: HeroExperience
)

test/scala/hero/model/HeroGenerators.scala:

package dev.kamgy
package hero

import hero.model.HeroModel.*
import org.scalacheck.*
import org.scalacheck.Gen.*

object HeroGenerators {
  def heroNameOpt(minLength: Int, maxLength: Int): Gen[Option[HeroName]] =
    Generators.alphaNumStrings(minLength, maxLength).map(HeroName.option.apply)
  def heroLevelOpt(minLevel: Int, maxLevel: Int): Gen[Option[HeroLevel]] =
    Gen.choose(minLevel, maxLevel).map(HeroLevel.option.apply)
  def heroExperienceOpt(minExperience: Int, maxExperience: Int): Gen[Option[HeroExperience]] =
    Gen.choose(minExperience, maxExperience).map(HeroExperience.option.apply)
}

test/scala/hero/model/HeroModelSpec.scala:

package dev.kamgy
package hero.model

import hero.HeroGenerators
import hero.model.HeroModel.{HeroExperience, HeroName}
import munit.ScalaCheckSuite
import org.scalacheck.Prop.*

class HeroModelSpec extends ScalaCheckSuite {

  property("HeroName should be invalid with less than 5 characters") {
    forAll(HeroGenerators.heroNameOpt(0, 4)) { heroNameOpt =>
      assert(heroNameOpt.isEmpty)
    }
  }

  property("HeroName should be invalid with more than 32 characters") {
    forAll(HeroGenerators.heroNameOpt(33, 64)) { heroNameOpt =>
      assert(heroNameOpt.isEmpty)
    }
  }

  property("HeroName should be invalid if a valid-length name contains non-alphanumeric characters") {
    forAll(HeroGenerators.heroNameOpt(5, 31), Generators.specialChars) {
      case (Some(validName), specialChar) =>
        val invalidName = validName + specialChar
        assert(HeroName.option(invalidName).isEmpty)
      case _ => fail("Generator produced invalid HeroName unexpectedly")
    }
  }

  property("HeroName should be valid with 5 to 32 characters") {
    forAll(HeroGenerators.heroNameOpt(5, 32)) { heroNameOpt =>
      assert(heroNameOpt.isDefined)
    }
  }

  property("HeroLevel should be invalid below 1") {
    forAll(HeroGenerators.heroLevelOpt(-1000, 0)) { heroLevelOpt =>
      assert(heroLevelOpt.isEmpty)
    }
  }

  property("HeroLevel should be valid for positive values") {
    forAll(HeroGenerators.heroLevelOpt(1, 10000)) { heroLevelOpt =>
      assert(heroLevelOpt.isDefined)
    }
  }

  property("HeroExperience should be invalid below 0") {
    forAll(HeroGenerators.heroExperienceOpt(-1_000_000, -1)) { heroExperienceOpt =>
      assert(heroExperienceOpt.isEmpty)
    }
  }

  test("HeroExperience should be valid for 0") {
    assert(HeroExperience.option(0).isDefined)
  }

  property("HeroExperience should be valid for positive values") {
    forAll(HeroGenerators.heroExperienceOpt(1, 1_000_000)) { heroExperienceOpt =>
      assert(heroExperienceOpt.isDefined)
    }
  }
}

Location Model

src/main/scala/location/model/LocationModel.scala:

package dev.kamgy
package location.model

import java.util.UUID

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.collection.Empty
import io.github.iltotore.iron.constraint.string.Alphanumeric

object LocationModel {
  type LocationId = LocationId.T

  object LocationId extends RefinedType[UUID, Pure] {
    def generate(): LocationId = LocationId.assume(UUID.randomUUID())
  }

  type LocationName = LocationName.T
  object LocationName extends RefinedType[String, Not[Empty] & Alphanumeric]
}

src/main/scala/location/model/Location.scala:

package dev.kamgy
package location.model

import location.model.Location.*
import location.model.LocationModel.{LocationId, LocationName}

case class Location(
  id: LocationId,
  name: LocationName
)

test/scala/location/model/LocationGenerators.scala:

package dev.kamgy
package location

import location.model.LocationModel.*
import org.scalacheck.Gen
import org.scalacheck.Gen.*

object LocationGenerators {
  def locationNameOpt(minLength: Int, maxLength: Int): Gen[Option[LocationName]] =
    Generators.alphaNumStrings(minLength, maxLength).map(LocationName.option.apply)
}

test/scala/location/model/LocationModelSpec.scala:

package dev.kamgy
package location.model

import dev.kamgy.location.model.LocationModel.LocationName
import location.LocationGenerators
import munit.ScalaCheckSuite
import org.scalacheck.Prop.*

class LocationModelSpec extends ScalaCheckSuite {

  test("LocationName should be invalid when empty") {
    assert(LocationName.option("").isEmpty)
  }

  property("LocationName should be invalid when it contains non-alphanumeric character") {
    forAll(LocationGenerators.locationNameOpt(1, 255), Generators.specialChars) {
      case (Some(validName), specialChar) =>
        val invalidName = validName + specialChar
        assert(LocationName.option(invalidName).isEmpty)
      case _ => fail("Generator produced invalid LocationName unexpectedly")
    }
  }

  property("LocationName should be valid for non-empty alphanumeric strings") {
    forAll(LocationGenerators.locationNameOpt(1, 256)) { locationNameOpt =>
      assert(locationNameOpt.isDefined)
    }
  }
}

Creature Model

src/main/scala/creature/model/CreatureModel.scala:

package dev.kamgy
package creature.model

import java.util.UUID

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.collection.Empty
import io.github.iltotore.iron.constraint.numeric.Positive
import io.github.iltotore.iron.constraint.string.Alphanumeric

object CreatureModel {
  type CreatureId = CreatureId.T

  object CreatureId extends RefinedType[UUID, Pure] {
    def generate(): CreatureId = CreatureId.assume(UUID.randomUUID())
  }

  type CreatureName = CreatureName.T
  object CreatureName extends RefinedType[String, Not[Empty] & Alphanumeric]

  type CreatureExperienceReward = CreatureExperienceReward.T
  object CreatureExperienceReward extends RefinedType[Int, Positive]
}

src/main/scala/creature/model/Creature.scala:

package dev.kamgy
package creature.model

import creature.model.CreatureModel.*

case class Creature(
  id: CreatureId,
  name: CreatureName,
  experienceReward: CreatureExperienceReward
)

test/scala/creature/model/CreatureGenerators.scala:

package dev.kamgy
package creature

import creature.model.CreatureModel.*
import org.scalacheck.Gen
import org.scalacheck.Gen.*

object CreatureGenerators {
  def creatureNameOpt(minLength: Int, maxLength: Int): Gen[Option[CreatureName]] =
    Generators.alphaNumStrings(minLength, maxLength).map(CreatureName.option.apply)
  def experienceRewardOpt(min: Int, max: Int): Gen[Option[CreatureExperienceReward]] =
    Gen.choose(min, max).map(CreatureExperienceReward.option.apply)
}

test/scala/creature/model/CreatureModelSpec.scala:

package dev.kamgy
package creature.model

import creature.CreatureGenerators
import dev.kamgy.creature.model.CreatureModel.CreatureName
import munit.ScalaCheckSuite
import org.scalacheck.Gen
import org.scalacheck.Prop.*

class CreatureModelSpec extends ScalaCheckSuite {

  test("CreatureName should be invalid when empty") {
    CreatureName.option("").isEmpty
  }

  property("CreatureName should be invalid when it contains non-alphanumeric character") {
    forAll(CreatureGenerators.creatureNameOpt(1, 128), Generators.specialChars) {
      case (Some(validName), specialChar) =>
        val invalidName = validName + specialChar
        assert(CreatureName.option(invalidName).isEmpty)
      case _ => fail("Generator produced invalid CreatureName unexpectedly")
    }
  }

  property("CreatureName should be valid for non-empty alphanumeric") {
    forAll(CreatureGenerators.creatureNameOpt(1, 128)) { creatureNameOpt =>
      assert(creatureNameOpt.isDefined)
    }
  }

  property("CreatureExperienceReward should be invalid below 1") {
    forAll(CreatureGenerators.experienceRewardOpt(-100, 0)) { rewardOpt =>
      assert(rewardOpt.isEmpty)
    }
  }

  property("CreatureExperienceReward should be valid for positive values") {
    forAll(CreatureGenerators.experienceRewardOpt(1, 1_000_000)) { rewardOpt =>
      assert(rewardOpt.isDefined)
    }
  }
}

Item Model

src/main/scala/item/model/ItemModel.scala:

package dev.kamgy
package item.model

import java.util.UUID

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.collection.Empty
import io.github.iltotore.iron.constraint.numeric.Positive
import io.github.iltotore.iron.constraint.string.Alphanumeric

object ItemModel {
  type ItemId = ItemId.T

  object ItemId extends RefinedType[UUID, Pure] {
    def generate(): ItemId = ItemId.assume(UUID.randomUUID())
  }

  type ItemName = ItemName.T
  object ItemName extends RefinedType[String, Not[Empty] & Alphanumeric]

  type ItemPrice = ItemPrice.T
  object ItemPrice extends RefinedType[Int, Positive]
}

src/main/scala/item/model/Item.scala:

package dev.kamgy
package item.model

import item.model.ItemModel.*

case class Item(
  id: ItemId,
  name: ItemName,
  price: ItemPrice
)

test/scala/item/model/ItemGenerators.scala:

package dev.kamgy
package item

import item.model.ItemModel.*
import org.scalacheck.Gen

object ItemGenerators {
  def itemNameOpt(minLength: Int, maxLength: Int): Gen[Option[ItemName]] =
    Generators.alphaNumStrings(minLength, maxLength).map(ItemName.option.apply)
  def itemPriceOpt(minPrice: Int, maxPrice: Int): Gen[Option[ItemPrice]] =
    Gen.choose(minPrice, maxPrice).map(ItemPrice.option.apply)
}

test/scala/item/model/ItemModelSpec.scala:

package dev.kamgy
package item.model

import dev.kamgy.item.model.ItemModel.ItemName
import item.ItemGenerators
import munit.ScalaCheckSuite
import org.scalacheck.Prop.*

class ItemModelSpec extends ScalaCheckSuite {

  test("ItemName should be invalid when empty") {
    assert(ItemModel.ItemName.option("").isEmpty)
  }

  property("ItemName should be invalid when it contains non-alphanumeric character") {
    forAll(ItemGenerators.itemNameOpt(1, 255), Generators.specialChars) {
      case (Some(validName), specialChar) =>
        val invalidName = validName + specialChar
        assert(ItemName.option(invalidName).isEmpty)
      case _ => fail("Generator produced invalid ItemName unexpectedly")
    }
  }

  property("ItemName should be valid for non-empty alphanumeric strings") {
    forAll(ItemGenerators.itemNameOpt(1, 256)) { itemNameOpt =>
      assert(itemNameOpt.isDefined)
    }
  }

  property("ItemPrice should be invalid below 1") {
    forAll(ItemGenerators.itemPriceOpt(-1000, 0)) { priceOpt =>
      assert(priceOpt.isEmpty)
    }
  }

  property("ItemPrice should be valid for positive values") {
    forAll(ItemGenerators.itemPriceOpt(1, 1_000_000)) { priceOpt =>
      assert(priceOpt.isDefined)
    }
  }
}

Summary

Congratulations - we’ve completed our models! We now have a solid foundation for our game and can start building the more exciting stuff. See you in the next part! 👋

As always, you can find the complete code in the GitHub repository .