Mastodon

Dungeon 14. Salir con un tesoro. Parte 2

por Fran Iglesias

En esta entrega lidiaré con varias jerarquías de herencias, así como con estrategias para mantener mayor consistencia en la gestión de eventos. Además, solucionaremos problemas de usabilidad.

En la entrega anterior comenté que refactorizaría, entre otras cosas, la forma en que se usan los eventos. De esta manera, en lugar de basarme en un string con su nombre para identificarlos, lo hago en el nombre de la clase. Gracias a ello, la aplicación gana consistencia.

Aquí un enlace al último commit hasta ahora

En cuanto al juego en sí, el siguiente aspecto en que me interesa centrarme es en la creación de nuevos objetos del juego. Hasta ahora, tenemos un objeto Thing genérico, aunque no tiene gran uso. De momento, ha servido sobre todo para implementar acciones como coger, guardar y usar. Sin embargo, el único objeto que aparece en la mazmorra es una unidad de comida que sirve para que la jugadora pueda recuperar energía y es el único uso implementado que tenemos.

Para los objetivos de esta iteración, queremos tener la posibilidad de que la jugadora pueda coleccionar algunos objetos que sirvan como llave para salir una vez encuentre la puerta adecuada. Incluso aunque no tengan otro uso en el juego, al menos de momento.

Los objetos que se pueden encontrar en la mazmorra pueden tener varios usos. Algunos servirán para cambiar propiedades de la jugadora (como el nivel de energía). Otros no tendrán una aplicación específica, pero se pueden usar para marcar estancias o simplemente coleccionarlos. Algunos actuarán como llaves para atravesar puertas, etc.

Un problema que hay que resolver es el de su identificación. Por ejemplo, en la mazmorra puede haber un número indefinido de objetos de tipo Food, y para poder tener varios en la misma estancia o en la mochila deben identificarse de forma única. Esta identificación se basa ahora mismo en su nombre, pero debería permitirnos diferenciar distintas instancias.

Otra cuestión es que queremos darle un poco de teatralidad al juego, por lo que nos vendría de perlas poder añadir descripciones ricas a los objetos y al juego en general. Con eso podríamos ambientar las sesiones en distintos contextos. De hecho, la mazmorra podría ser tanto una mazmorra en su sentido clásico, como una nave espacial perdida, un sistema de cuevas, una ciudad…

Mejorando la clase Thing para poder tener más objetos en la mazmorra

Así que observemos la clase Thing:

class Thing:
    def __init__(self, name):
        self._name = name

    def name(self):
        return self._name

    def apply_on(self, user):
        user.increase_energy(EnergyUnit(10))

Como vemos, no tiene gran cosa. Pero sí que podemos observar un problema. El método apply_on es muy específico de un tipo de objetos. Es una especialización, la manera en particular que un subtipo de Thing se aplica.

Esto me lleva a pensar que Thing debería ser una clase base y querremos derivar de ella una clase Food, por ejemplo. Y también otras, por supuesto, para diferentes modalidades de comportamiento.

Por otro lado, la única propiedad de Thing es su nombre. Una cosa es un nombre y otra un identificador. El nombre nos sirve para mostrarlo en pantalla. El identificador para poder buscarlo encontrar el objeto en las distintas colecciones de las que puede formar parte.

Desde el punto de vista de la jugadora lo que conocemos es el nombre del objeto y esa sería la forma de referenciarlo en el juego: get Food, use Sword. El problema vendría si tenemos varios objetos que podrían llamarse igual.

Por ejemplo, en una mazmorra podríamos situar inicialmente 5 ó 6 objetos Food, ¿Cómo podríamos distinguirlos? La forma más sencilla es llamarlos de manera distinta.

Creo que ahora mismo es lo más sencillo. Obligar a que todos los nombres sean distintos y derivar sus identificadores de ellos. Los identificadores son de uso interno, y necesitamos forzar algunas propiedades, como un caso uniforme (por ejemplo, lowercase). Debería ser sencillo derivar un identificador de un nombre.

Por eso, sería interesante introducir value objects. Nos ayudan a garantizar ciertas invariantes y nos permiten encapsular las reglas para normalizarlos. Empezaríamos por aquí:

class ThingName:
    def __init__(self, name):
        self._name = name


class ThingId:
    def __init__(self, id):
        self._id = id

Ambos requieren no ser una cadena vacía. ThingId tiene que garantizar que es lowercase. En ThingName no hace falta. Además, querríamos poder construir ThingId a partir de un ThingName.

Lo voy a desarrollar con TDD, aunque en lugar de mostrarlo paso a paso, ya te pongo todos los tests aquí:

from unittest import TestCase

from dungeon.app.domain.thing import ThingName, ThingId


class TestThingName(TestCase):
    def test_should_not_be_created_empty(self):
        with self.assertRaises(ValueError):
            ThingName("")


class TestThingId(TestCase):
    def test_should_not_be_created_empty(self):
        with self.assertRaises(ValueError):
            ThingId("")

    def test_should_be_normalized_to_lowercase(self):
        identifier = ThingId.normalized("SomeIdForThing")
        self.assertEqual("someidforthing", identifier.to_s())

    def test_could_be_derived_from_ThingName(self):
        identifier = ThingId.from_name(ThingName("A Thing"))
        self.assertEqual("a thing", identifier.to_s())

Y las implementaciones correspondientes:

class ThingName:
    def __init__(self, name):
        if len(name) == 0:
            raise ValueError("Things must have name")
        self._name = name

    def to_s(self):
        return self._name


class ThingId:
    def __init__(self, id):
        if len(id) == 0:
            raise ValueError("Things must have identifier")
        self._id = id

    def to_s(self):
        return self._id

    @classmethod
    def normalized(cls, id):
        return cls(id.lower())

    @classmethod
    def from_name(cls, thing_name):
        return cls.normalized(thing_name.to_s())

Lo más interesante es que sigo algunas pautas de Elegant Objects, introduciendo varias formas de construcción dependiendo de las intenciones. Me pregunto si sería adecuado hacer la validación también en un constructor secundario, pero de momento lo dejo así porque me parece más conciso.

Ahora tocaría empezar a aplicar estos nuevos objetos. Hay varios sitios en los que construimos objetos Thing. Para simplificar un poco he pensado en crear un constructor secundario que tome un nombre para el objeto y cree todos los objetos necesarios:

class TestThing(TestCase):
    def test_could_be_created_from_raw_name(self):
        a_thing = Thing.from_raw("Food")
        self.assertEqual("food", a_thing.id().to_s())

Que implemento así:

class Thing:
    def __init__(self, name, id):
        self._name = name
        self._id = id

    def name(self):
        return self._name

    def id(self):
        return self._id

    def apply_on(self, user):
        user.increase_energy(EnergyUnit(10))

    @classmethod
    def from_raw(cls, name):
        thing_name = ThingName(name)
        thing_id = ThingId.from_name(thing_name)
        return cls(thing_name, thing_id)

Ahora lo sustituyo en los lugares adecuados. El cambio es fácil de hacer. Obviamente, se rompen algunos tests porque estamos usando el método name para simular el identificador en algunas partes del código. Debemos cambiar eso para emplear ThingId siempre.

Vamos a ver algunos casos particulares que me interesan:

def _is_a_different_object(self, thing_name):
    return self._holds.id().to_s() != ThingId.normalized(thing_name).to_s()

Aquí tengo dos ideas. La más inmediata es que necesitamos métodos equal para ThingId, de modo que podamos compararlos directamente.

La otra idea es que self._holds debería ser un objeto y encapsular varios comportamientos que necesitamos. Dejaré esto para otro momento, pero me parece importante.

def test_could_compare_for_equality(self):
    an_id = ThingId("example")
    another_id = ThingId("example")

    self.assertEqual(an_id, another_id)

En Python podemos sobreescribir el método __eq__ que se usa en estas comparaciones.

def __eq__(self, other):
    return self.to_s() == other.to_s()

Y usarlo:

def _is_a_different_object(self, thing_name):
    return self._holds.id() != ThingId.normalized(thing_name)

Otra área que se ve afectado un poco negativamente es Backpack y Things, con problemas muy parecidos:

class Backpack:
    def __init__(self):
        self._items = dict()

    def append(self, item):
        self._items[item.id().to_s()] = item

    def content(self):
        content = []
        for key, item in self._items.items():
            content.append(item.name().to_s())

        return ", ".join(content)

    def get(self, thing_name):
        thing_id = ThingId.normalized(thing_name)
        if thing_id.to_s() in self._items.keys():
            return self._items.pop(thing_id.to_s())

Técnicamente, es posible usar ThingId como key de un diccionario, pero el problema es que no siempre tenemos la misma instancia de ThingId con la que creamos una clave.

Pero esto se puede solventar si sobreescribimos su función __hash__ para que si tienen el mismo valor, se consideren iguales para las necesidades del diccionario.

class ThingId:
    def __init__(self, id):
        if len(id) == 0:
            raise ValueError("Things must have identifier")
        self._id = id

    def __eq__(self, other):
        return self.to_s() == other.to_s()

    def __hash__(self):
        return hash(self._id)

    def to_s(self):
        return self._id

    @classmethod
    def normalized(cls, id):
        return cls(id.lower())

De este modo, podemos cambiar Backpack y quedará un código un poco más limpio:

class Backpack:
    def __init__(self):
        self._items = dict()

    def append(self, item):
        self._items[item.id()] = item

    def content(self):
        content = []
        for key, item in self._items.items():
            content.append(item.name().to_s())

        return ", ".join(content)

    def get(self, thing_name):
        thing_id = ThingId.normalized(thing_name)
        if thing_id in self._items.keys():
            return self._items.pop(thing_id)

Y lo mismo en Things.

class Things:
    def __init__(self):
        self._things = dict()

    def put(self, a_thing):
        self._things[a_thing.id()] = a_thing

    def look(self):
        if len(self._things) > 0:
            response = "There are:\n"
            for thing in self._things.values():
                response += "* {}\n".format(thing.name().to_s())
        else:
            response = "There are no objects\n"
        return response

    def get(self, thing_name):
        thing_id = ThingId.normalized(thing_name)
        if thing_id in self._things.keys():
            return self._things.pop(thing_id)
        return None

Este tipo de técnicas dependen bastante del lenguaje, pero son útiles para tener un código más claro.

Hay una gran similitud entre Backpack y Things, porque implementan la misma estructura de datos aunque representan distintos conceptos. También dejo para otro momento extraer esa estructura a una nueva clase. Esta nueva clase no será una clase base de la que extiendan ambas, sino que reemplazará al diccionario.

Ahora toca empezar a desarrollar nuestra familia de objetos. La idea es que Thing se convierta en una clase base y derivemos diversos tipos de objetos en función de comportamientos que queramos que tengan.

Así, por ejemplo, en nuestro caso está claro que Food es una categoría que nos interesa: objetos que aportan energía a la jugadora. Algo así:

class Food(Thing):
    def apply_on(self, user):
        user.increase_energy(EnergyUnit(10))

Una nota a tener en cuenta es que querríamos poder definir objetos Food que aporten distintas cantidades de energía. Lo veremos en un momento. Antes vamos a introducir el objeto en el código.

Para eso, también vamos a dejar sin definir el comportamiento de la clase Thing, lo que nos facilitará descubrir los que tenemos que cambiar para ciertos tests. En los demás, el objeto Thing no se usa, por lo que nos da un poco igual el tipo concreto o su nombre.

class Thing:
    def apply_on(self, some_object):
        pass

Por ejemplo, este test:

@expect_event_equal(PlayerEnergyChanged, "energy", EnergyUnit(58))
def test_using_food_makes_player_increase_energy(self):
    dungeon = self.dungeon_with_object(Food.from_raw("Banana"))

    player = Player(EnergyUnit(50))
    player.awake_in(dungeon)
    player.register(self.observer)

    player.do(GetCommand("Banana"))
    player.do(UseCommand("Banana"))

Para permitirnos configurar los objetos Food tenemos que admitir un parámetro en la creación que defina la cantidad de energía que aportará. Para facilitar el cambio, primero permitiré que este parámetro sea opcional y tenga un valor por defecto:

class Food(Thing):
    def __init__(self, name, id, energy=EnergyUnit(10)):
        super().__init__(name, id)
        self._energy = energy

    def apply_on(self, user):
        user.increase_energy(self._energy)

Probamos los tests para asegurarnos de que no se ha roto nada y, en un segundo paso, sobreescribimos el método factoría from_raw:

class Food(Thing):
    @classmethod
    def from_raw(cls, name, energy=EnergyUnit(10)):
        thing_name = ThingName(name)
        thing_id = ThingId.from_name(thing_name)
        return cls(thing_name, thing_id, energy)

    def __init__(self, name, id, energy):
        super().__init__(name, id)
        self._energy = energy

    def apply_on(self, user):
        user.increase_energy(self._energy)

Introduciendo llaves

Ahora podríamos introducir algunos tipos de objetos nuevos. Por ejemplo, Key. Como primera aproximación a nuestro objetivo de establecer algún tipo de misión para que la puerta de salida se pueda abrir, tiene sentido introducir un objeto Key.

La misma idea de un objeto que sea una llave, me sugiere fuertemente que se pueda configurar con algún tipo de clave que compartiría con el objeto Door, de modo que sería fácil comprobar que usamos la llave adecuada en la puerta correcta. Creo que esto no impediría otros requisitos para salir de la mazmorra, por ejemplo, pero me parece una solución interesante para incorporar en el juego llaves y puertas.

Esta podría ser una primera aproximación:

class Key(Thing):
    @classmethod
    def from_raw(cls, name, key):
        thing_name = ThingName(name)
        thing_id = ThingId.from_name(thing_name)
        think_key = ThingKey(key)
        return cls(thing_name, thing_id, think_key)

    def __init__(self, name, id, key):
        super().__init__(name, id)
        self._key = key

    def apply_on(self, door):
        pass

El objeto ThingKey es un value object que contendrá la clave de la llave y ofrecerá métodos para hacer match y desbloquear la puerta correspondiente.

Este pseudo test describe lo que esperamos que pase, aunque no tenemos todavía un objeto LockedDoor (LockedExit podría ser algo parecido y eso me lleva a pensar si no debería existir un Locked que sea un decorador de Door y Exit). Si no se usa una llave (o no es válida), la puerta no se abre y no se puede pasar. Si se usa la llave correcta, la puerta nos deja atravesarla.

class TestLockedDoor(TestCase):
    @expect_event(PlayerHitWall)
    def test_should_be_unlocked_with_the_right_key(self):
        locked_door = LockedDoor("supersecret")
        locked_door.go()
    
    @expect_event(PlayerMoved)
    def test_should_be_unlocked_with_the_right_key(self):
        locked_door = LockedDoor("supersecret")
        key = Key.from_raw("TheKey", "supersecret")
        key.apply_on(locked_door)
        locked_door.go()

El desafío está en diseñar cómo van a interaccionar jugadora y puerta para que se pueda utilizar la llave. La solución más sencilla me parece que es que la jugadora pueda usar la llave, que abrirá o no la puerta cerrada. Pero ¿cómo sabe la jugadora sobre qué puerta aplicar la llave?

Actualmente, este es el modo en que Player usa un objeto:

def use(self, thing_name):
    if self._holds is None:
        self._notify_observers(ActionNotCompleted("You need an object to use it."))
        return
    if self._is_a_different_object(thing_name):
        return
    self._holds.apply_on(self)
    self._holds = None

Para el caso de Key, hay varias cosas que tendrían que cambiar:

  • El objeto se tiene que poder aplicar en otro que no sea Player. ¿Cómo podemos saber eso?
  • Podría ser que el objeto permanezca después de ser usado. Los objetos Food tiene sentido que desaparezcan, pues se consumen. Un objeto Key podría permanecer, aunque no se vuelva a utilizar.
  • Por otro lado, su efecto puede ser permanente en el objeto que lo recibe.

Creo que la mayor dificultad es el primer punto. Para el segundo me planteo que tiene que ser algo que el propio objeto Thing sea pueda definir. Por ejemplo, algo así:

def use(self, thing_name):
    
    # Removed code
    
    self._holds = self._holds.apply_on(self)

Es decir, al usar un Thing deberíamos obtener de vuelta o bien el mismo objeto o bien None o un NullThing. Esto es lo que me ha llevado a pensar que self._holds debería ser otra cosa diferente de lo que es ahora: un contenedor de un solo elemento con su propio comportamiento.

Este cambio lo podemos hacer ya, dado que la única implementación de apply_on no devuelve None de forma implícita.

A continuación voy a retomar la idea de los pseudo-test anteriores, pero usando un decorador Locked en lugar de crear especializaciones de Door y de Exit. La idea es que Locked funcione de tal modo que si no se usa la llave, haga que no se pueda pasar por esa puerta. El evento DoorIsLocked sirve para alertar a la jugadora de que no tiene la llave correspondiente.

class TestLockedDoor(TestCase):

    def setUp(self) -> None:
        self.observer = FakeObserver()

    @expect_event(DoorWasLocked)
    def test_should_be_locked_if_no_key(self):
        locked_door = Locked(Door("another_place"), ThingKey("supersecret"))
        locked_door.register(self.observer)
        locked_door.go()

Mi primera implementación es esta:

class Locked(Wall):
    def __init__(self, door, secret):
        self._door = door
        self._secret = secret
        self._is_locked = True
        self._subject = Subject()

    def go(self):
        self._notify_observers(DoorWasLocked())

Ahora añado el comportamiento de abrir usando la llave:

@expect_event(PlayerMoved)
def test_should_be_unlocked_with_the_right_key(self):
    locked_door = Locked(Door("another_place"), ThingKey("supersecret"))
    locked_door.register(self.observer)
    key = Key.from_raw("TheKey", "supersecret")
    key.apply_on(locked_door)
    locked_door.go()

Comportamiento que podría implementarse de esta forma. Si la cerradura se abre con la llave adecuada, Locked delega la llamada en el objeto al que decora. Podemos ver también como Locked tiene que delegar el registro de observadores para que el objeto decorado pueda notificarles.

class Locked(Wall):
    def __init__(self, door, secret):
        self._door = door
        self._secret = secret
        self._is_locked = True
        self._subject = Subject()

    def go(self):
        if self._is_locked:
            self._notify_observers(DoorWasLocked())
        else:
            self._door.go()

    def unlock_with(self, key):
        self._is_locked = not key.match(self._secret)

    def register(self, observer):
        super().register(observer)
        self._door.register(observer)

Por su parte, así queda Key:

class Key(Thing):
    
    # Removed code

    def apply_on(self, door):
        door.unlock_with(self._key)
        return self

En dónde hay que usar la llave

El punto que me queda por resolver es de qué forma se podrá usar una llave en una puerta concreta. Las opciones que se me ocurren son:

  • Usar la llave en todas las posibles puertas que haya en la celda, que es un poco fuerza bruta y muy específico de este problema.
  • Poder indicar un destinatario de la acción de usar, o de cualquier otra. Por ejemplo, poder decir use key with north door.
  • Introducir una acción open que requiera que la jugadora sostenga una Key y que contenga una referencia a la puerta deseada. Esto evita introducir el procesamiento de más argumentos en el intérprete de comandos, aunque sigue teniendo la dificultad de identificar la puerta sobre la que se aplicará. Open vendría a ser un use específico para llaves.

La primera opción me parece claramente la peor.

La segunda requiere poder manejar argumentos extra en el intérprete de comandos, lo que puede introducir cierta complejidad que, a lo mejor, no se necesita más allá de este caso concreto.

La tercera opción me parece que es una buena solución intermedia, aunque introduce un elemento un poco extraño, como es que exista un requisito para aplicar una acción.

En estos casos, lo mejor es iniciar un spike, o sea hacer un experimento en código para averiguar como de factible es una solución o qué va a requerir.

Para hacer spike puede ser buena idea usar una rama específica, incluso cuando haces Trunk Based Development. De este modo, puedes seguir trabajando en el código de producción sin que los cambios experimentales interfieran. Para un spike podemos relajar un poco las prácticas (TDD, código simple, etc.), ya que no lo vamos a incorporar tal cual.

En este spike quiero probar cómo podría funcionar la tercera opción: un comando open que se aplicará teniendo una llave.

class OpenCommand(Command):
    def __init__(self, argument):
        super().__init__(argument)
        
    def name(self):
        return "open"

Como decíamos antes, el comando open es similar al use, por lo que me baso en él para empezar a construirlo. En pocas palabras, el comando solo puede ejecutarse si sostenemos un objeto que sea una llave. Dado que Player._receiver siempre va a ser Dungeon, necesitaremos que exponga algún método para acceder a una puerta específica:

def open(self, door_dir):
    if self._holds is None:
        self._notify_observers(ActionNotCompleted("You need a key to open something."))
        return
    if not isinstance(self._holds, Key):
        self._notify_observers(ActionNotCompleted("You need a key to open something."))
        return
    self._holds = self._holds.apply_on(self._receiver.door(Dir(door_dir)))

Obviamente, esto se expresaría mejor con tests. Este primer test describe el comportamiento cuando no tenemos ningún objeto en la mano. La mazmorra para test es esta:

def dungeon_with_locked_exit(self):
    builder = DungeonBuilder()
    builder.add('start')
    builder.set('start', Dir.N, Locked(Exit(), ThingKey("super-secret")))
    builder.put('start', Key.from_raw("key", "super-secret"))
    builder.put('start', Food.from_raw("food"))
    return builder.build()

Y este es el test:

@expect_event(ActionNotCompleted)
def test_not_having_key_does_not_open_doors(self):
    dungeon = self.dungeon_with_locked_exit()
    dungeon.register(self.observer)
    player = Player()
    player.awake_in(dungeon)
    player.register(self.observer)
    player.do(OpenCommand("north"))

Este test muestra lo que ocurre si intentamos abrir una puerta con el objeto que no es:

@expect_event(ActionNotCompleted)
def test_object_that_is_not_a_key_does_not_open_doors(self):
    dungeon = self.dungeon_with_locked_exit()
    dungeon.register(self.observer)
    player = Player()
    player.awake_in(dungeon)
    player.register(self.observer)
    player.do(GetCommand("food"))
    player.do(OpenCommand("north"))

Este otro test nos muestra el happy path de usar la llave correcta y abrir la puerta:

@expect_event(PlayerExited)
def test_key_allows_open_door_and_go_through_it(self):
    dungeon = self.dungeon_with_locked_exit()
    dungeon.register(self.observer)
    player = Player()
    player.awake_in(dungeon)
    player.register(self.observer)
    player.do(GetCommand("key"))
    player.do(OpenCommand("north"))
    player.do(GoCommand("north"))

Para hacer pasar este test solo nos quedaría implementar un método door en Dungeon.

class Dungeon:

    # Removed code
    
    def door(self, direction):
        return self._current_room().door(direction)

Que, a su vez, delegaría en Room:

class Room:
    
    # Removed code
    
    def door(self, direction):
        return self._walls.get(direction)

A grandes rasgos, el spike me ha servido para comprobar que el planteamiento es factible, aunque faltaría definir el comportamiento cuando la llave no es la correcta. El código obtenido no está mal, pero ahora que lo veo me planteo otras formas de expresarlo. Como hemos comentado más arriba, el objetivo del spike es obtener un mejor conocimiento de lo que queremos conseguir mediante la prueba de hipótesis y soluciones.

Probando de nuevo el juego

Hay un tema pendiente, además, que es probar este código jugando. En este caso, el problema que se observa es que la información sobre el uso de las llaves no es lo bastante buena. Tenemos que consguir que Printer gestione los eventos relacionados y, tal vez, introducir algún evento más que permita informar mejor de lo que está ocurriendo.

De hecho, estos son todos los problemas que nos hemos encontrado al introducir un par de llaves en el juego y tener la salida cerrada con llave:

  • Si hay una pared cerrada con llave, “look around” muestra que hay una pared, pero no que es una puerta.
  • No tenemos feedback al intentar pasar una puerta cerrada con llave.
  • No tenemos feedback de qué objeto tenemos en la mano.
  • Al usar una llave tampoco tenemos feedback del resultado.
  • La llave correcta no abre la puerta.
  • El juego no finaliza aunque nos hayamos quedado sin energía.
  • Si indicamos una dirección equivocada, el juego termina abruptamente
  • Vendría bien un comando para acabar el juego de forma limpia, o empezar de nuevo si es el caso

Son unos cuantos errores para arreglar. Vamos a revisarlos.

Arreglar varios problemas mejorando la jerarquía de clases

Si hay una pared cerrada con llave, “look around” muestra que hay una pared, pero no que es una puerta. Este podría ser debido Locked no sobreescribe el método description, por lo que se delega automáticamente en Wall, que es su clase base.

Es un caso de acoplamiento debido a herencia y revela una mala jerarquía. Ahora mismo esta jerarquía es algo así como:

Wall
  +--Door
  +--Exit
  +--Locked (decorator)

Siendo Locked un decorador de Door y Exit. No tiene mucho sentido que Locked forme parte de la misma jerarquía, pero queremos que cumpla la misma interfaz. Son cosas diferentes. Además, nunca aplicaríamos el decorador Locked en Wall ya que no tiene sentido semántico.

Si lo pienso bien, creo que tendría más sentido esto:

Boundary
    +--Wall
    +--Door
         +--Exit
         +--Locked (decorator)

Boundary representaría cualquier límite de una celda, usualmente una pared (Wall) o una puerta (Door).

class Boundary:
    def go(self):
        pass

    def look(self):
        pass

    def description(self):
        pass

Exit es un tipo de puerta especial en tanto que nos lleva fuera de la mazmorra. Locked no deja de ser una puerta, pero que está cerrada con una llave.

En cualquier caso, si pedimos la descripción de Locked por defecto nos debería indicar que es una puerta y que está cerrada con llave. Podemos refactorizar la jerarquía y luego corregir el problema.

Una vez arreglada la jerarquía he vuelto a jugar y ahora la descripción de las habitaciones es más correcta, mostrando las puertas, aunque sin indicar si están cerradas o no.

exit
--------------------------------------
There are no objects
North: There is a door
East: There is a door
South: There is a wall
West: There is a wall
That's all

Remaining energy: 35
======================================

Además, ahora sí que podemos salir del juego. No tengo claro por qué ha cambiado, pero esta vez funciona correctamente.

You said: go east

exit
--------------------------------------
Congrats. You're out
Remaining energy: 29
======================================

Por cierto, que he tenido que cambiar este test que prueba que se puede salir de la mazmorra de juego:

class GameDungeonTestCase(unittest.TestCase):
    def setUp(self):
        self.observer = FakeObserver()

    @expect_event(PlayerExited)
    def test_we_can_complete_dungeon(self):
        dungeon = DungeonFactory().make('game')
        dungeon.register(self.observer)
        player = Player()
        player.register(self.observer)
        player.awake_in(dungeon)

        player.do(GoCommand('north'))
        player.do(GoCommand('north'))
        player.do(GoCommand('north'))
        player.do(GoCommand('east'))
        player.do(GoCommand('north'))
        player.do(GoCommand('east'))
        player.do(GoCommand('east'))
        player.do(GoCommand('south'))
        player.do(GoCommand('west'))
        player.do(GetCommand('RedKey'))
        player.do(GoCommand('east'))
        player.do(GoCommand('south'))
        player.do(OpenCommand('east'))
        player.do(GoCommand('east'))

Para conseguir que se muestre que la puerta está cerrada con llave:

exit
--------------------------------------
There are no objects
North: There is a door
East: There is a door (locked)
South: There is a wall
West: There is a wall
That's all

Remaining energy: 54
======================================

Basta hacer este cambio:

class Locked(Door):
    
    # Removed code

    def description(self):
        return "{} (locked)".format(self._door.description())

    # Removed code

Eventos olvidados

No tenemos feedback al intentar pasar una puerta cerrada con llave. Este es otro de los errores que tenemos que corregir. En este caso, pienso que lo que falla es no manejar el evento DoorWasLocked en Printer.

class Printer:
    
    # Removed code

    def notify(self, event):
        if event.of_type(PlayerEnergyChanged):
            self._energy = str(event.energy().value())

        # Removed code    
        
        elif event.of_type(DoorWasLocked):
            self._description = "The door is locked. You will need a key."

Con lo que conseguimos esto al intentar atravesar una puerta sin tener llave:

You said: go east

exit
--------------------------------------
The door is locked. You will need a key.
Remaining energy: 45
======================================

Saber qué tenemos en la mano

No tenemos feedback de qué objeto tenemos en la mano. Este problema viene de que no notificamos los cambios de los objetos que tenemos en la mano. Posiblemente, nos bastaría con hacer algo similar a lo que tenemos con Backpack.

Hay varios sitios en los que el contenido de la “mano” puede cambiar, como por ejemplo aquí en Player:

def get(self, thing_name):
    thing = self._backpack.get(thing_name)
    if thing is not None:
        if self._holds is not None:
            self._backpack.append(self._holds)
            self._holds = None
        self._holds = thing
        self._notify_observers(BackpackChanged(self._backpack.content()))

Tendríamos que notificar a los observes de este cambio para que Printer lo tenga en cuenta y lo muestre.

class ThingInHandChanged(Event):
    def __init__(self, content):
        self._content = content

    def content(self):
        return self._content

El evento se lanzaría en dos lugares de Player.get:

def get(self, thing_name):
    thing = self._backpack.get(thing_name)
    if thing is not None:
        if self._holds is not None:
            self._backpack.append(self._holds)
            self._holds = None
            self._notify_observers(ThingInHandChanged(ThingName('nothing')))
        self._holds = thing
        self._notify_observers(BackpackChanged(self._backpack.content()))
        self._notify_observers(ThingInHandChanged(self._holds.name()))

Y también aquí:

def _do_get_thing(self, event):
    if self._holds is not None:
        self._receiver.drop(self._holds)
        self._holds = None
    self._holds = event.thing()
    self._notify_observers(ThingInHandChanged(self._holds.name()))

Con esto, Printer solo tiene que mostrarlo:

class Printer:
    
    # Removed code

    def notify(self, event):
        if event.of_type(PlayerEnergyChanged):
            self._energy = str(event.energy().value())

        # Removed code
            
        elif event.of_type(BackpackChanged):
            self._description = "Your backpack now contains: {}".format(event.content())
        elif event.of_type(ThingInHandChanged):
            self._description = "Your hand now holds: {}".format(event.content().to_s())
        elif event.of_type(DoorWasLocked):
            self._description = "The door is locked. You will need a key."

    # Removed code

Y eso es lo que ocurre:

What should I do? >get redkey
You said: get redkey

8
--------------------------------------
Your hand now holds: RedKey
Remaining energy: 42
======================================

Una pequeña mejora sería reemplazar esto:

if self._holds is not None:
    self._backpack.append(self._holds)
    self._holds = None
    self._notify_observers(ThingInHandChanged(ThingName('nothing')))

Con esto:

if self._holds is not None:
    self._backpack.append(self._holds)
    self._holds = None
    self._notify_observers(ThingInHandChanged(ThingNullName()))

Como he mencionado en algún momento, más tarde me ocuparé de gestionar mejor todo lo que tiene que ver con el objeto “en la mano”.

Feedback de las llaves

Al usar una llave tampoco tenemos feedback del resultado. Al usar la llave nada parece cambiar:

You said: open east

exit
--------------------------------------
There are no objects
North: There is a door
East: There is a door (locked)
South: There is a wall
West: There is a wall
That's all

Remaining energy: 28
======================================

Esto es porque no notificamos nada cuando usamos la llave. Aparte de eso, diría que Locked no usa su información de estado para modificar su descripción. Como mínimo, tendríamos que asegurarnos de esto.

class Locked(Door):
    def __init__(self, door, secret):
        self._door = door
        self._secret = secret
        self._is_locked = True
        self._subject = Subject()

    def go(self):
        if self._is_locked:
            self._notify_observers(DoorWasLocked())
        else:
            self._door.go()

    def description(self):
        return "{} (locked)".format(self._door.description())

    def unlock_with(self, key):
        self._is_locked = not key.match(self._secret)
        return

    # Removed code

Es bastante obvio lo que habría que hacer aquí: cambiar el mensaje de la descripción en función del estado de self._is_locked.

def description(self):
    template = "{} (locked)" if self._is_locked else "{} (unlocked)"
    return template.format(self._door.description())

La otra pata es lanzar un evento. Esto podemos hacerlo en el método unlock_with.

class DoorWasUnlocked(Event):
    def __init__(self):
        pass

En principio no tenemos una forma sencilla de identificar la puerta. Así que, de momento, lo dejaremos así.

def unlock_with(self, key):
    self._is_locked = not key.match(self._secret)
    self._notify_observers(DoorWasUnlocked())

Por supuesto, Printer tiene que saber de este evento:

class Printer:
    
    # Removed code

    def notify(self, event):
        if event.of_type(PlayerEnergyChanged):
            self._energy = str(event.energy().value())
        
        # Removed code
        
        elif event.of_type(DoorWasUnlocked):
            self._description = "The door is unlocked."

¿Y si no es la llave correcta?

De hecho, si intentamos usar la llave incorrecta en una puerta, nos dará el mensaje de que la hemos desbloqueado.

Esto es porque notificamos el evento incondicionalmente. Nos convendría poner unos tests aquí, cosa que no hemos hecho antes.

def unlock_with(self, key):
    self._is_locked = not key.match(self._secret)
    self._notify_observers(DoorWasUnlocked())

Este test puede valer:

class TestLocked(TestCase):

    def setUp(self) -> None:
        self.observer = FakeObserver()

    @expect_event(DoorWasUnlocked)
    def test_unlock_with_the_right_key(self):
        door = Door("dest")
        locked_door = Locked(door, ThingKey("secret"))
        locked_door.register(self.observer)
        key = Key.from_raw("Key", "secret")

        key.apply_on(locked_door)

    @expect_event(DoorWasLocked)
    def test_unlock_with_the_wrong_key(self):
        door = Door("dest")
        locked_door = Locked(door, ThingKey("secret"))
        locked_door.register(self.observer)
        key = Key.from_raw("Key", "wrong")

        key.apply_on(locked_door)

Voy a usar el evento DoorWasLocked aunque no sea del todo preciso, ya que proporcionará un feedback adecuado.

def unlock_with(self, key):
    self._is_locked = not key.match(self._secret)
    what_happened = DoorWasLocked() if self._is_locked else DoorWasUnlocked()
    self._notify_observers(what_happened)

Me estoy dando cuenta de que _is_locked me está suponiendo dificultades para interpretar el código, por lo que igual debería plantearme si seguir con este nombre o no.

En todo caso, ahora si usamos la llave equivocada, obtenemos un feedback adecuado.

Finalizar el juego por agotamiento

El juego no finaliza aunque nos hayamos quedado sin energía. Lo primero es comprobar que esto sigue ocurriendo. Efectivamente, podemos estar con energía negativa.

What should I do? >go east
You said: go east

4
--------------------------------------
You moved to room '4'
Remaining energy: -8
======================================

¿Es posible que este evento nunca llegue a notificarse?

class Game:

    # Removed code

    def notify(self, event):
        if event.of_type(PlayerExited):
            self._finished = True
        if event.of_type(PlayerDied):
            self._finished = True

Pues algo así, puesto que Game no estaba suscrito a Player para escuchar sus eventos.

class Game:
    
    # Removed code
    
    def run(self, player, dungeon):
        player.register(self)
        dungeon.register(self)
        player.awake_in(dungeon)
        self._printer.draw()
        while not self.finished():
            player.do(self._input.command())
            self._printer.draw()

El problema es que la salida del juego es abrupta, aunuqe sin errores. Simplemente no se dice nada en pantall, y sospecho que ahora pasaría lo mismo al salir con éxito.

Lo ideal sería que se pueda ejecutar una última vez la llamada a Printer.draw(). La alternativa más rápida es hacer esto:

def run(self, player, dungeon):
    player.register(self)
    dungeon.register(self)
    player.awake_in(dungeon)
    self._printer.draw()
    while not self.finished():
        player.do(self._input.command())
        self._printer.draw()
    self._printer.draw()

Y si ya era un poco feo antes… imagínate ahora que lo repetimos tres veces. La cuestión es que necesitamos:

  • Mostrar la pantalla antes de empezar el juego para que se pueda ver la bienvenida y, en su caso, alguna descripción inicial del juego.
  • Mostrar la respuesta a las acciones de la jugadora cada vez que introduce un comando.
  • Mostrar el resultado cuando la última acción acaba en muerte o saliendo de la mazmorra.

Una opción sería considerar la primera y última vez como casos especiales, para mostrar pantallas específicas de bienvenida y despedida. Esto justificaría las llamadas anterior y posterior al bucle, que podrían ser algo así como:

printer.welcome()
printer.goodbye()

Y por el momento pueden ser proxies a draw, hasta que decidamos si necesitan más comportamiento.

class Printer:
    
    # Removed code

    def draw(self):
        scene = Scene(
            title=self._title,
            command=self._command,
            description=self._description,
            energy=self._energy
        )

        return self.show_output.put(scene)

    def welcome(self):
        return self.draw()

    def goodbye(self):
        return self.draw()

Salidas abruptas

Hay dos últimos problemas que me gustaría tratar y que son las salidas abruptas. Tengo dos casos:

  • Si indicamos una dirección equivocada, el juego termina abruptamente
  • Vendría bien un comando para acabar el juego de forma limpia, o empezar de nuevo si es el caso

En el primero, lo que necesito es capturar el error y tratarlo como si fuese un comando mal tecleado o desconocido. En este caso, el error ocurre cuando intentamos generar un objeto Dir con una dirección equivocada.

class Dungeon:

    # Removed code

    def go(self, direction):
        self._current_room().go(Dir(direction))

    # Removed code

Podríamos hacerlo así. La acción no se completa porque no se entiende.

class Dungeon:
    
    # Removed code

    def go(self, direction):
        try:
            self._current_room().go(Dir(direction))
        except ValueError:
            self._notify_observers(ActionNotCompleted("I don't know how to go {}.".format(direction)))

    # Removed code

El segundo caso es un nuevo comando que me permita terminar el juego. Esto nos lleva a plantear un problema interesante: ¿se puede preservar el estado de la partida para retomarlo más adelante?

Guardar la partida tiene varios retos y es mucho para tratarlo ahora. Habría que recordar:

  • La celda en la que se encuentra la jugadora
  • Su nivel de energía actual
  • Los objetos que tiene en su poder, tanto en la mochila como en la mano
  • Los objetos que quedan en la mazmorra y sus posiciones, ya que pueden haber sido movidos por la jugadora

Por el momento, vamos a crear un comando (ByeCommand) que nos permita salir limpiamente tecleando bye.

class ByeCommand(Command):
    def __init__(self):
        pass
        
    def name(self):
        return "bye"

Como hemos visto hace un momento, nos basta que este comando permita a Player notificar un evento para que Game lo escuche y cambie self._finished a True. Printer también podría escuchar el evento, de modo que ponga un mensjae al respecto.

class PlayerAsSubjectTestCase(unittest.TestCase):

    # Removed code

    @expect_event(PlayerFinishedGame)
    def test_wants_to_finish_game(self):
        self.player.do(ByeCommand())

Y esto es lo que añadimos a Player para reaccionar el comando. Usamos *args para aceptar que se pase un parámetro cuando no vamos a hacer nada con él.

class Player:
    
    # Removed Code

    def bye(self, *args):
        self._notify_observers(PlayerFinishedGame())

Hacemos que Game sepa reaccionar al evento:

class Game:
    
    # Removed code

    def notify(self, event):
        if event.of_type(PlayerExited):
            self._finished = True
        if event.of_type(PlayerDied):
            self._finished = True
        if event.of_type(PlayerFinishedGame):
            self._finished = True

    def finished(self):
        return self._finished

Y vamos a hacer que Printer también lo haga:

class Printer:
    
    # Removed code
    
    def notify(self, event):
        if event.of_type(PlayerEnergyChanged):
            self._energy = str(event.energy().value())

        # Removed code
        
        elif event.of_type(PlayerFinishedGame):
            self._title = "You decided to leave the game."
            self._description = "Thanks for playing. See you soon."

Con lo que obtenemos esto al dejar de jugar:

You said: bye 

You decided to leave the game.
--------------------------------------
Thanks for playing. See you soon.
Remaining energy: 94
======================================

Y he aquí el estado del código en este momento.

Próximos pasos

Esta iteración ha sido intersante porque además de incorporar la funcionalidad de usar las llaves con las puertas, he podido arreglar varios asuntos que no estaban funcionando bien.

Salir con una llave no es exactamente lo que me había planteado inicialmente, pero es un paso en esa dirección. El segundo paso sería establecer un sistema de “misiones” en el que se requiriese encontrar ciertos objetos y salir con ellos.

Pero bueno, creo que el hecho de introducir puertas y llaves puede hacer más interesante el juego ya que no solo hay que encontrar la salida, sino que esta podría estar cerrada con llave, o podría haber pasajes cerrados con llave, por lo que hay que moverse por más celdas en su busca. Eso incrementa el riesgo de quedarse sin energía. En resumen: ahora el juego puede ser más emocionante.

Lo que sería necesario hacer es crear un laberinto más intersante, que incluya estos elementos.

Desde el punto de vista técnico, hemos visto un buen ejemplo de como tener una mala o buena jerarquía de clases puede perjudicar o ayudar en la prevención de errores.

En un próximo artículo usaré algunos ejemplos de Dungeon para profundizar en la gestión de jerarquías de clases, uso de composición en lugar de herencia y cómo manejar el acoplamiento en ambas situaciones.

Por lo que respecta a Dungeon, ahora mismo no tengo claro por donde querría seguir ya que hay varios temas que han ido saliendo que me interesan. Por ejemplo:

  • Añadir nuevos tipos de objetos.
  • Diseñar nuevas mazmorras y, posiblemente, un sistema para que el diseño sea declarativo.
  • Establecer una configuración y preferencias, de modo que se pueda escoger la mazmorra, nivel de energía inicial y otros parámetros.
  • Poder guardar una partida y retomarla.
  • Misiones.
  • Enemigos.

¿A ti qué te parece?

January 4, 2023

Etiquetas: python   good-practices   dungeon  

Temas

good-practices php refactoring testing tdd python blogtober19 design-principles bdd misc legacy dungeon design-patterns tips tools ddd bbdd soft-skills golang ruby javascript books api sql oop ethics swift java