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 toHero
and implement theLocation
,Creature
, andItem
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 .