Primeramente, debemos definir nuestro modelo de dominio. Para ello definiremos algunas clases dentro del archivo com/example/package.scala.
Como primer paso, podemos definir una clase Name que represente el nombre del jugador:
final case class Name(name: String)
Como podemos ver, definimos Name como una clase case en vez de definirla simplemente como una clase normal. Usar clases case nos da muchos beneficios, como por ejemplo:
Además, definimos la clase como final para que no pueda ser extendida por otras clases. Por otro lado, vemos que la clase Name simplemente encapsula un String que representa el nombre del usuario. Podríamos dejar este modelo así como está, sin embargo hay un problema. Nos permite crear objetos Name con un String vacío, lo cual es algo indeseado. Entonces, para impedir esto, podemos aplicar una técnica de diseño funcional llamada constructor inteligente (smart constructor en inglés), que simplemente consiste en definir un método en el objeto acompañante de la clase Name que nos permita hacer las validaciones respectivas antes de crear una instancia. Entonces, tendríamos algo así:
final case class Name(name: String)
object Name {
def make(name: String): Option[Name] =
if (!name.isEmpty) Some(Name(name)) else None
}
Como podemos ver, el método make corresponde a nuestro constructor inteligente, y éste verifica si el String recibido es vacío o no. Si no es vacío, devuelve un nuevo Name, pero encapsulado dentro de un Some, y si es vacío retorna un None. Lo importante a resaltar aquí es que el método make es una función pura, porque es total, determinística, la salida sólo depende del String de entrada y no produce efectos colaterales.
Ahora bien, hay algunas cosas que podemos mejorar en nuestra implementación. Por ejemplo, aún es posible llamar directamente al constructor de Name con Strings vacíos. Para evitar esto, podemos convertirlo en privado:
final case class private Name(name: String)
object Name {
def make(name: String): Option[Name] =
if (!name.isEmpty) Some(Name(name)) else None
}
De esta forma, la única forma de construir objetos Name es llamando al método Name.make, al menos eso es lo que se espera. Sin embargo, aún podemos construir objetos Name con Strings vacíos usando el método Name.apply. Por otro lado, hay también otro problema que debemos resolver, el cual es que gracias al método copy que es automáticamente generado para las clases case, después de haber creado un objeto Name válido usando Name.make, podríamos luego generar un objeto inválido generando una copia con un String vacío, y nada nos impediría hacer eso. Para evitar esto, podemos definir la clase como sealed abstract en vez de final, lo cual significa que no se generará los métodos apply y copy:
sealed abstract case class Name private (name: String)
object Name {
def make(name: String): Option[Name] =
if (!name.isEmpty) Some(new Name(name) {}) else None
}
De esta forma, estamos completamente seguros que cualquier objeto Name en nuestra aplicación siempre será válido.
Similarmente, podemos definir una clase case llamada Guess, que represente cualquier letra adivinada por el jugador:
sealed abstract case class Guess private (char: Char)
object Guess {
def make(str: String): Option[Guess] =
Some(str.toList).collect {
case c :: Nil if c.isLetter => new Guess(c.toLower) {}
}}
Como podemos ver, hemos usado la misma técnica de definir un constructor inteligente, el cual recibe un String introducido por el jugador, y verifica si solamente consiste de una letra. Si ése no es el caso (por ejemplo cuando el String introducido por el jugador consiste de varios caracteres o cuando introduce un número o un símbolo) se retorna None.
Luego, podemos definir otra clase case llamada Word, que represente la palabra a ser adivinada por el jugador:
sealed abstract case class Word private (word: String) {
def contains(char: Char) = word.contains(char)
val length: Int = word.length
def toList: List[Char] = word.toList
def toSet: Set[Char] = word.toSet
}
object Word {
def make(word: String): Option[Word] =
if (!word.isEmpty && word.forall(_.isLetter)) Some(new Word(word.toLowerCase) {})
else None
}
Otra vez tenemos un constructor inteligente que verifica que una palabra no sea vacía y que contenga solamente letras, no números ni símbolos. Adicionalmente esta clase contiene algunos métodos útiles (todos son funciones puras, por cierto):
A continuación definimos otra clase case llamada State, la cual representa el estado interno de la aplicación, que incluye:
sealed abstract case class State private (name: Name, guesses: Set[Guess], word: Word) {
def failuresCount: Int = (guesses.map(_.char) -- word.toSet).size
def playerLost: Boolean = failuresCount > 5
def playerWon: Boolean = (word.toSet -- guesses.map(_.char)).isEmpty
def addGuess(guess: Guess): State = new State(name, guesses + guess, word) {}
}
object State {
def initial(name: Name, word: Word): State = new State(name, Set.empty, word) {}
}
Similarmente, tenemos un constructor inteligente State.initial, que permite instanciar el estado inicial del juego con el nombre del jugador y la palabra que tiene que adivinar, obviamente con un Set vacío de letras adivinadas. Por otro lado, State incluye algunos métodos útiles:
Finalmente, definimos un modelo GuessResult que representa el resultado obtenido por el jugador después de adivinar una letra. Este resultado puede ser uno de los siguientes casos:
Esto lo podemos representar con una enumeración, de la siguiente forma:
sealed trait GuessResult
object GuessResult {
case object Won extends GuessResult
case object Lost extends GuessResult
case object Correct extends GuessResult
case object Incorrect extends GuessResult
case object Unchanged extends GuessResult
}
En este caso una enumeración tiene sentido pues GuessResult sólo puede tener un valor posible de entre los mencionados en la lista de opciones. Algunos detalles importantes aquí: