Mastodon

Dungeon 13. Salir con un tesoro. Parte 1

por Fran Iglesias

Después de darle unas vueltas creo que lo siguiente que me gustaría que pasase en Dungeon es que para salir haya que haber conseguido encontrar un tesoro.

Bueno, puede no ser un tesoro. El caso es que para dar interés al juego y que sea necesario recorrer la mazmorra antes de salir, pienso que se puede poner una misión a la jugadora. Esta misión sería encontrar un objeto o varios.

Al llegar a la puerta de salida tendría que verificarse que la misión se ha cumplido para poder salir. Si no, es como una puerta cerrada con llave o una pared.

¿Qué necesitamos preparar para esto?

  • El comando collect porque hemos dicho que ese es el que nos permitirá coger objetos que encontremos y guardarlos. Para guardarlos, necesitaremos algún tipo de Bag o colección de objetos que vivirá dentro de Player y que tendrá una capacidad limitada.
  • También vamos a necesitar un comando drop, ya que en ocasiones querremos deshacernos de los objetos que llevamos y dejarlos en alguna celda.
  • Que los objetos Exit puedan interactuar con Player. Esto es, Exit es una pared que responde al método go permitiendo la salida de la mazmorra. Necesitamos introducir una modificación que le permita poner condiciones para permitir el paso. Una forma de hacer esto es mediante un patrón Specification que pueda usar el contenido de Bag y averiguar si contiene el objeto u objetos que hacen de llave.
  • Este mismo modelo se puede aplicar a cualquier puerta que queramos cerrar o abrir con llave. Lo único que hace especial a Exit es que es la puerta de salida de la mazmorra.

Esta nueva feature abre la puerta bastantes temas interesantes:

  • Herencia y polimorfismo: necesitaremos introducir nuevos tipos de objetos, ahora solo tenemos uno, con distintos comportamientos. Y también nuevas Door y Exit con nuevos comportamientos.
  • Mejorar la identificación de objetos y separarla de su descripción.
  • Al incorporar nuevos comandos, podríamos considerar una forma de auto-descubrimiento en la factoría.
  • Introducir más objetos en la mazmorra, nuevos tipos de puertas y “misiones”, nos puede llevar a reconsiderar algún lenguaje de configuración de las mazmorras.

La parte buena es que creo que el código está ahora en mejor estado para introducir nuevas funcionalidades.

Por supuesto, ahora no vamos a intentar abordarlo todo a la vez. Así que vamos a intentar rebanar esta nueva característica del juego.

Me parece bastante obvio que implementar el comando collect es lo primero que deberíamos estar haciendo. Una vez que Player es capaz de coger objetos y transportarlos.

Hay que recordar también que Player tendría que poder coger (get) o usar objetos que lleva en la bolsa. Bien permitiendo que get pueda aplicarse a objetos de la bolsa, o bien haciendo un get implícito antes. Esto podría implementarse ahora o más tarde.

Después podríamos incorporar nuevos tipos de objetos. De momento pueden no tener uso, pero se podrían coleccionar. Por ejemplo, objetos Key para abrir puertas.

El tercer paso sería implementar una versión de Exit que pueda chequear si Player lleva consigo la Key adecuada. Aquí tendríamos que introducir una manera de definir esto (se me ocurre que esa LockedExit guarde una referencia al objeto u objetos Key que la abre.

Antes de continuar, querría señalar que en los tests veremos varios decoradores que he ido introduciendo para hacerlos más expresivos y concisos.

Implementando el comando Collect

Pero antes un refactor preparatorio

En principio CollectCommand solo tendrá que llamar al método collect en su receiver que será normalmente Player. Otros comandos se aplican sobre otros receivers. Mientras escribo esto, pienso que un nombre mejor que receiver sería handler

En cualquier caso, normalmente antes hacer nada me gusta ver el contexto. En este caso, este método de Player;

def _execute_command(self, command, receiver):
    if command.name() == "use":
        command.do(self)
        self._last_command = command
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
        return
    command.do(receiver)
    self._last_command = command
    self._notify_observers(PlayerSentCommand(command.name(), command.argument()))

Dos cosas:

  • El if sugiere polimorfismo. Cuando quiera introducir el commando collect, tendré que añadirlo a la lista de comandos que se aplican a Player. Pero es un smell, ya que estoy consultando un tipo para decidir qué hacer con el objeto.
  • La duplicación. Lo único que cambia en cada rama del if es el objeto que se pasa al comando. Esto se ve más fácilmente si añadimos else:
def _execute_command(self, command, receiver):
    if command.name() == "use":
        command.do(self)
        self._last_command = command
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
    else:
        command.do(receiver)
        self._last_command = command
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))

Podríamos refactorizarlo así, en dos pasos:

def _execute_command(self, command, receiver):
    if command.name() == "use":
        effective_receiver = self
        command.do(effective_receiver)
        self._last_command = command
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
    else:
        effective_receiver = receiver
        command.do(effective_receiver)
        self._last_command = command
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))

Para acabar con:

def _execute_command(self, command, receiver):
    if command.name() == "use":
        effective_receiver = self
    else:
        effective_receiver = receiver

    command.do(effective_receiver)
    self._last_command = command
    self._notify_observers(PlayerSentCommand(command.name(), command.argument()))

Que en Python puede expresarse así:

def _execute_command(self, command, receiver):
    effective_receiver = self if command.name() == "use" else receiver
    command.do(effective_receiver)
    self._last_command = command
    self._notify_observers(PlayerSentCommand(command.name(), command.argument()))

No es la solución final, pero deja este espacio un poco mejor.

En realidad, lo que queremos es poder hacer una especie de cadena de responsabilidad y pasar el comando tanto a self como a cualquier receiver que tengamos. Cada uno de ellos debería ver si puede aplicarse o no en el receiver.

Una posible forma de hacer esto es que antes de ejecutar command.do se verifique si self lo puede ejecutar. Si no, lo pasa a receiver. O sea, algo así:

def _execute_command(self, command, receiver):
    effective_receiver = self if hasattr(self, command.name()) else receiver
    command.do(effective_receiver)
    self._last_command = command
    self._notify_observers(PlayerSentCommand(command.name(), command.argument()))

Esto está mejor porque ahora no necesitamos añadir una lista creciente de comandos que tendríamos que actualizar para chequear a quien tenemos que enviar el comando. De este modo, este método de Player cumple mejor con Open/Close.

Como side note, he tenido que hacer un pequeño arreglo en el FakeCommand de test para que devuelva su nombre.

class FakeCommand(Command):
    def __init__(self, energy_consumption):
        self._energy_consumption = energy_consumption

    def cost(self):
        return self._energy_consumption

    def name(self):
        return "fake"

Con todo, no me convence del todo la solución. Sigo preguntándole al objeto algo para decidir cómo usarlo. Esto viola Tell, don’t ask y me gustaría evitarlo.

Y puedo hacerlo con algo de meta programación. Al fin y al cabo, tenemos una especie de convención que nos dice que todos los receivers que quieran ejecutar un comando deben tener un método cuyo nombre sea igual al del comando. Simplemente Command se ejecutará si el receiver tiene el método adecuado.

Usando esta información, podríamos hacer esta modificación:

class Command:

    def __init__(self, argument):
        self._argument = argument

    def do(self, receiver):
        if hasattr(receiver, self.name()):
            getattr(receiver, self.name())(self.argument())

    def name(self):
        return ""

    def cost(self):
        return EnergyUnit(1)

    def argument(self):
        if hasattr(self, "_argument"):
            return self._argument

        return ""

De este modo, no necesitamos implementar el método do en ningún objeto de tipo Command. Por ejemplo:

class UseCommand(Command):
    def __init__(self, argument):
        super().__init__(argument)

    def name(self):
        return "use"

Todos los tests siguen pasando. Si necesitase personalizar el comportamiento de uno de estos objetos no tendría más que sobreescribir el método do.

¿Y cómo afecta esto a Player? Básicamente ahora podemos enviar el comando recibido a sí mismo o al receiver, en la confianza de que lo ejecutará aquel de los dos que le de soporte.

    def _execute_command(self, command, receiver):
        command.do(self)
        command.do(receiver)
        self._last_command = command
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))

Este refactor nos facilita introducir nuevos comandos ya que no tenemos que tocar nada en Player aparte de añadir un método en caso de que le vayamos a dar soporte.

No obstante, sí que tendríamos que añadirlo en CommandFactory para que se pueda instanciar. Y como cada vez son más comandos los que necesitamos, puede que nos venga bien otro poco de auto-magia, digo, de meta programación.

Una factoría que descubre los comandos

No soy especialmente fan de estas técnicas, pero manteniéndolas en el contexto adecuado creo que pueden ser de ayuda.

La cuestión es que Python ofrece utilidades suficientes como para que los objetos Command puedan ser descubiertos por CommandFactory e instanciados siguiendo unas convenciones simples. De este modo, cada vez que introduzca un comando nuevo podré usarlo sin necesidad de tocar la factoría, extendiendo la funcionalidad de Dungeon simplemente añadiendo código.

La funcionalidad de Python sobre la que se va a basar este autodescubrimiento de comandos es la posibilidad de importar módulos e instanciar clases dinámicamente. Lo único que necesito es respetar algunas convenciones:

  • Los comandos se guardan en el package dungeon.app.command.commands.
  • Cada comando vive en un módulo llamado [nombre_del_comando]_command.
  • El nombre de la clase para instanciar es [Nombre]Command y recibe un parámetro.

Ahora mismo me molestan un par de cosas de CommandFactory. Principalmente, el hecho de que se use de forma estática. Me interesaría que tengo un motor de instanciación de comandos inyectable, de este modo puedo aislar convenientemente la parte meta programada.

Creo que puedo arreglar esto mediante una técnica de cambio paralelo. Introduzco el código en la forma nueva en que lo quiero usar primero y luego reemplazo el uso viejo.

Vamos primero a desarrollar el motor auto-mágico con un test que pruebe que puedo instanciar un comando:

class TestCommandFactoryAutodiscoveryEngine(TestCase):
    def test_autodiscover_commands(self):
        autodiscover = Autodiscover("dungeon.app.command.commands")
        command = autodiscover.by_name("go", "argument")
        self.assertIsInstance(command, GoCommand)

En principio quiero hacerlo configurable, pasándole la ruta en la que buscar los comandos. La idea es que con el comando que escribe la usuaria podamos encontrar el comando deseado.

Implementar esto no es muy complicado. import_module importa un módulo definido dinámicamente, mientras que podemos instanciar una clase obteniéndola del módulo que la contiene.

class Autodiscover:
    def __init__(self, path_to_commands):
        self._path_to_commands = path_to_commands

    def by_name(self, command, argument):
        module = importlib.import_module(self._path_to_commands + "." + command + "_command")
        command_class = getattr(module, command.capitalize() + "Command")
        return command_class(argument)

Para aclarar un poco el código:

class Autodiscover:
    def __init__(self, path_to_commands):
        self._path_to_commands = path_to_commands

    def by_name(self, command, argument):
        module = import_module("{path}.{command}_command".format(path=self._path_to_commands, command=command))
        command_class = getattr(module, "{name}Command".format(name=command.capitalize()))
        return command_class(argument)

Una cosa que me interesa es obtener un InvalidCommand en el caso de que no se encuentre el comando buscado, bien por un error o porque no está definido.

    def test_fallback_to_invalid_if_no_command_found(self):
        autodiscover = Autodiscover("dungeon.app.command.commands")
        command = autodiscover.by_name("yadayadayada", "argument")
        self.assertIsInstance(command, InvalidCommand)

Esto debería ser suficiente:

class Autodiscover:
    def __init__(self, path_to_commands):
        self._path_to_commands = path_to_commands

    def by_name(self, command, argument):
        try:
            module = import_module("{path}.{command}_command".format(path=self._path_to_commands, command=command))
            command_class = getattr(module, "{name}Command".format(name=command.capitalize()))
        except ModuleNotFoundError:
            command_class = InvalidCommand
        return command_class(argument)

Ahora que tenemos un motor de auto descubrimiento de comandos, voy a extraer el motor manual.

class Custom:
    def by_name(self, command, argument):
        if command == "go":
            return GoCommand(argument)
        if command == "look":
            return LookCommand(argument)
        if command == "get":
            return GetCommand(argument)
        if command == "use":
            return UseCommand(argument)

        return InvalidCommand("{} {}".format(command, argument))

Y ahora hay que cambiar CommandFactory para que se le puedan inyectar los motores. Primero, la abstracción:

class CommandFactoryEngine:
    def by_name(self, command, argument):
        pass

Y quedaría así:

class CommandFactory:
    def __init__(self):
        self._engine = Custom()

    def from_user_input(self, user_input):
        try:
            command, argument = user_input.split(" ", 1)
        except ValueError:
            command = user_input
            argument = "around"

        return self._engine.by_name(command, argument)

Esto va a romper el uso que tenemos ahora, así que vamos a modificarlo:

class ConsoleObtainUserCommand(ObtainUserCommand):

    def command(self):
        raw = input("What should I do? >")
        user_input = " ".join(raw.lower().strip().split())
        factory = CommandFactory()
        return factory.from_user_input(user_input)

Hay que hacer el mismo cambio en varios tests, así que una vez que todos vuelven a pasar, consolido estos últimos cambios antes de dar el último paso. Hago que engine sea un parámetro opcional para construir CommandFactory.

class CommandFactory:
    def __init__(self, engine=Custom()):
        self._engine = engine

    def from_user_input(self, user_input):
        try:
            command, argument = user_input.split(" ", 1)
        except ValueError:
            command = user_input
            argument = "around"

        return self._engine.by_name(command, argument)

Y finalmente inyecto el motor de auto descubrimiento:

class ConsoleObtainUserCommand(ObtainUserCommand):

    def command(self):
        raw = input("What should I do? >")
        user_input = " ".join(raw.lower().strip().split())
        factory = CommandFactory(Autodiscover("dungeon.app.command.commands"))
        return factory.from_user_input(user_input)

Lo suyo sería seguir el proceso e inyectar CommandFactory.

class ConsoleObtainUserCommand(ObtainUserCommand):
    def __init__(self, command_factory):
        self._command_factory = command_factory

    def command(self):
        raw = input("What should I do? >")
        user_input = " ".join(raw.lower().strip().split())
        return self._command_factory.from_user_input(user_input)

Hay que hacer una modificación en el entry_point y en algún test, pero es sencillo:

def main(args=None):
    if args is None:
        args = sys.argv[1:]

    toggles = Toggles()
    factory = CommandFactory(Autodiscover("dungeon.app.command.commands"))
    obtain_user_command = ConsoleObtainUserCommand(factory)
    application = Application(obtain_user_command, RichConsoleShowOutput(), DungeonFactory(), toggles)
    application.run()


if __name__ == "__main__":
    sys.exit(main())

Este ha sido un largo rodeo para poder empezar a implementar CollectCommand. Pero ahora nos bastaría con añadir la clase para que esté disponible.

Ahora sí, vamos a coleccionar

En principio CollectCommand invocará el método collect en Player. Ahora no existe, pero como ya hemos trabajando en eso, no debería pasar nada si lo introducimos e intentamos usarlo:

class CollectCommand(Command):
    def __init__(self, argument):
        super().__init__(argument)

    def name(self):
        return "collect"

De hecho, si lo usamos no pasará nada porque ni Player no Dungeon tienen un método para usarlo. Sin embargo, como podemos comprobar si lo usamos, consume energía, prueba de que se ha invocado.

Welcome to the Dungeon
--------------------------------------

Remaining energy: 100
======================================
What should I do? >collect food
You said: collect food

Welcome to the Dungeon
--------------------------------------

Remaining energy: 99
======================================

Así que estamos listas para implementarlo. El comando collect es similar a get, con la diferencia de que Player añade el objeto a una colección que podríamos llamar Bag o Backpack (mochila). get es un comando que entiende Dungeon porque es Dungeon quien conoce los objetos disponibles. Comunica el resultado enviando un evento que escucha Player. Veámoslo aquí:

class Dungeon:
    
    # Removed code
    
    def get(self, thing_name):
        thing = self._current_room().get(thing_name)
        self._notify_observers(PlayerGotThing(thing))

Así que podemos hacer algo similar para collect. Veamos como desarrollarlo en TDD. Este primer test resultará familiar, porque es el mismo que usamos para GetCommand. Si la jugadora colecciona un objeto, éste ya no estará en la celda. Es un primer paso.

class CollectingThingsTestCase(unittest.TestCase):
    def setUp(self):
        self.builder = DungeonBuilder()
        self.observer = FakeObserver()
        self.player = Player()
        self.player.register(self.observer)

    @expect_event_containing("player_got_description", "description", "There are no objects")
    def test_player_collect_object_removes_from_room(self):
        dungeon = self.dungeon_with_object(Thing("Food"))
        dungeon.register(self.observer)
        self.player.awake_in(dungeon)
        self.player.do(CollectCommand("food"))
        dungeon.look('objects')

    def dungeon_with_object(self, thing=Thing("Food")):
        builder = DungeonBuilder()
        builder.add('start')
        builder.put('start', thing)
        return builder.build()

Así que empezamos con esto en Dungeon:

    def collect(self, thing_name):
        self._current_room().get(thing_name)

El siguiente paso es asegurar que Dungeon publica el evento adecuado:

    @expect_event("player_collected_thing")
    def test_dungeon_raises_event(self):
        dungeon = self.dungeon_with_object(Thing("Food"))
        dungeon.register(self.observer)
        self.player.awake_in(dungeon)
        self.player.do(CollectCommand("food"))

Este es el evento:

class PlayerCollectedThing:
    def __init__(self, thing):
        self._thing = thing

    def name(self):
        return "player_collected_thing"

    def thing(self):
        return self._thing

Y esto es lo que tiene que hacer Dungeon:

    def collect(self, thing_name):
        thing = self._current_room().get(thing_name)
        self._notify_observers(PlayerCollectedThing(thing))

La segunda parte es que Player extraiga el objeto del evento y lo añada a su Backpack. El problema puede ser cómo saber que se ha producido ese cambio. La respuesta podría ser usar otro evento para eso. Entre otras cosas porque nos interesa poder añadir esa información en la pantalla y ahora no le damos soporte. Un evento BackpackChanged podría llevar información del contenido de la mochila para que lo utilice Printer. Con esto podríamos empezar a escribir el test.

    @expect_event_containing("backpack_changed", "content", "Food")
    def test_player_added_item_to_backpack(self):
        dungeon = self.dungeon_with_object(Thing("Food"))
        dungeon.register(self.observer)
        self.player.awake_in(dungeon)
        self.player.do(CollectCommand("food"))

Queremos algo así:

class Player:
    
    # Removed code

    def notify(self, event):
        if "player_got_thing" == event.name():
            self._holds = event.thing()
        if "player_collected_thing" == event.name():
            self._backpack.append(event.thing())
            self._notify_observers(BackPackChanged(self._backpack.content()))

El evento quedará más o menos así:

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

    def name(self):
        return "backpack_changed"

    def content(self):
        return self._content

Y, de momento, ponemos toda la lógica que necesitamos en Backpack:

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

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

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

        return ", ".join(content)

Con esto queda definido el comportamiento de coleccionar objetos. Nos quedaría hacer que Printer conozca este evento y sepa hacer algo con él.

class Printer:

    # Removed code

    def notify(self, event):
        if event.name() == "player_energy_changed":
            self._energy = str(event.energy().value())
        
        # Removed code
        
        elif event.name() == "backpack_changed":
            self._description = "Your backpack now contains: {}".format(event.content())

Y así se puede ver:

What should I do? >collect Food
You said: collect food

10
--------------------------------------
Your backpack now contains: Food
Remaining energy: 70
======================================

Y parece que ya podemos recolectar objetos. Eso sí, nuestra mochila ahora mismo es ilimitada, auqnue no es problema porque no hay muchos objetos para recoger.

Tener objetos que se puedan recolectar y usar

Tener objetos guardados en la mochila no es útil si no los puedes usar. Bueno, para esta iteración nos valdría con que la puerta Exit pudiese verificarlos. Pero imagina la siguiente situación: coges un objeto “comida” y lo guardas… pero no lo puedes recuperar para comerlo cuando necesites un extra de energía.

En otras palabras, los comandos get y use deberían poder aplicarse a objetos que están en la mochila.

Por otro lado, los objetos de comida no deberían ser los únicos disponibles. De hecho, únicamente hemos definido un único tipo de objeto y no tenemos más opciones. Además de eso, recordemos que tenemos un temilla pendiente con la distinción entre objeto, su nombre y su identificador.

Personalmente, creo que lo primero sería resolver el problema de usar objetos de la mochila. La razón es porque resultaría muy frustrante para la jugadora una situación como la descrita hace un momento: tener un objeto que necesitas, pero que no puedes utilizar.

Lo interesante es que debería ser muy fácil de implementar gracias al refactor que hicimos al principio de esta entrega. Nos bastaría con implementar el método get en Player, de modo que si tienes un objeto adecuado en la mochila pases a tenerlo en la mano. Otra opción sería añadir al método use la capacidad de usar objetos que estén en la mochila.

Es importante recordar algunas reglas:

  • Cuando coges (get) un objeto deja de estar en su ubicación original (la celda o la mochila).
  • Hay objetos que se consumen al usarlos.

Vamos con get. Si implementamos get en Player se ejecutará y como queremos que coja objetos de la mochila, lo sabremos porque se lanza el evento backpack_changed.

Aprovecho que tenemos un test dedicado a Player cogiendo cosas, así que nos viene perfecto:, para añadir esta versión. Lo primero que vamos a verificar es que sostenga el objeto en la mano.

    def test_player_get_object_from_backpack_and_holds(self):
        thing = Thing("Food")
        dungeon = self.dungeon_with_object(thing)
        self.player.awake_in(dungeon)
        self.player.do(CollectCommand("food"))
        self.player.do(GetCommand("food"))
        self.assertEqual(thing, self.player.holds())

Y allá vamos:

    def test_player_get_object_and_holds(self):
        thing = Thing("Food")
        dungeon = self.dungeon_with_object(thing)
        self.player.awake_in(dungeon)
        self.player.do(GetCommand("food"))
        self.assertEqual(thing, self.player.holds())

Ha habido que hacer varios arreglos para conseguir el funcionamiento correcto de este comando. ¿Tenemos un problema de shotgun surgery?

Decimos que tenemos un problema de shotgun surgery cuando para introducir un cambio tenemos que tocar en muchos lugares del código. Esto indicaría falta de cohesión. Pero vamos a intentar revisar cuales han sido los problemas que hemos encontrado para analizarlo y ver en qué nos hemos equivocado.

Por ejemplo, un problema es que tanto las colecciones Things como Backpack fallan si el objeto que buscan no está, por lo que hay que controlar antes de devolverlos que existen. Aquí puedes ver la corrección:

class Things:
    
    # Removed code

    def get(self, thing_name):
        if thing_name in self._things.keys():
            return self._things.pop(thing_name.lower())
class Backpack:

    # Removed code

    def get(self, thing_name):
        if thing_name in self._items.keys():
            return self._items.pop(thing_name.lower())

Hay muchas similitudes en el funcionamiento de Things y Backpack, pero son conceptos de negocio distintos con algunas reglas diferentes. Things representa los objetos que hay en una habitación, mientras que Backpack representa los objetos que lleva la jugadora. En la primera colección no hay un límite preciso de la cantidad de objetos, mientras que en la segunda, sí queremos que lo haya.

El hecho de que tengamos que hacer la misma corrección viene de no haber considerado ese caso de uso: intentar coger un objeto que no está.

Este es un caso de duplicación de código en el que es fácil buscar la abstracción equivocada. En realidad, si queremos reducir esa duplicación sería a través de introducir una nueva estructura de datos, basada en el diccionario y adaptada a nuestras necesidades. Pero no una abstracción de negocio: no necesitamos un ThingsContainer que nos sirva para representar Things y Backpack, sino una estructura de diccionario enriquecida para soportar límite de capacidad, por un lado, y permanecer silenciosa si intentamos extraer un objeto que no existe.

Pero antes de ver si merece la pena atacar eso, tenemos que revisar un test que ha empezado a fallar a raíz de este cambio:

    @expect_event_equal("player_energy_changed", "energy", EnergyUnit(58))
    def test_using_food_makes_player_increase_energy(self):
        dungeon = self.dungeon_with_object(Thing("Food"))

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

        player.do(GetCommand("Food"))
        player.do(UseCommand("food"))

        self.assertIsNone(player.holds())

Un problema de haber usado un decorador para este test es que es un poco más difícil entender el error, que es que no coinciden los valores de energía con lo esperado. Por el momento, vamos a cambiarlo para ser más explícitos:

    def test_using_food_makes_player_increase_energy(self):
        dungeon = self.dungeon_with_object(Thing("Food"))

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

        player.do(GetCommand("Food"))
        player.do(UseCommand("food"))

        last = self.observer.last('player_energy_changed')
        self.assertEqual(58, last.energy().value())

        self.assertIsNone(player.holds())

El error revela que no se está incrementando la energía al consumir la comida. ¿Por qué?

Expected :58
Actual   :48

Porque no encuentra la comida por la diferencia de caso. De nuevo tenemos que corregir dos veces:

class Things:
    
    # Removed code

    def get(self, thing_name):
        if thing_name.lower() in self._things.keys():
            return self._things.pop(thing_name.lower())
class Backpack:

    # Removed code

    def get(self, thing_name):
        if thing_name.lower() in self._items.keys():
            return self._items.pop(thing_name.lower())

Obviamente, la causa raíz es la falta de consistencia de thing_name por usar primitivos y que aún no hemos arreglado. La duplicación de código contribuye a que el problema sea más difícil de identificar y probablemente nuestra vida será mejor abordándola correctamente.

Nos queda un último test para asegurar el comportamiento verificando que el objeto ha sido retirado de la mochila:

    @expect_event_containing("backpack_changed", "content", "")
    def test_player_get_object_removes_from_backpack(self):
        dungeon = self.dungeon_with_object(Thing("Food"))
        dungeon.register(self.observer)
        self.player.awake_in(dungeon)
        self.player.do(CollectCommand("food"))
        self.player.do(GetCommand("food"))

El test pasa con el código que ya tenemos. Gracias a ello, hemos completado la implementación del comando get para que mire también en la mochila.

Casos a los que prestar atención

Hay algunas cuestiones de negocio en esta funcionalidad que tenemos que abordar en algún momento:

  • Si ya tengo un objeto en la mano ¿A dónde va si queremos coger otro? Ahora mismo, el objeto en la mano desaparecería. Teniendo en cuenta que ahora sabemos de dónde viene el objeto (si viene de la mochila o de la habitación), simplemente podríamos intercambiarlo el que estamos sosteniendo con el nuevo en su ubicación. Creo que puede ser un punto de partida suficiente.
  • El límite de objetos en la mochila y qué consecuencias tiene. En principio si se alcanza el límite no se pueden poner objetos en la mochila.
  • Una de estas consecuencias es que necesitamos implementar el comando drop, para quitar objetos de la mochila (o de la mano) y dejarlos en la habitación actual.

Así que vamos a implementar nuestra primera preocupación; qué hacer con el objeto que tenemos en la mano si queremos coger otro.

Vamos a empezar con un test en el que recogemos dos objetos de la habitación consecutivamente. Queremos quedarnos con el último en la mano y devolver el primero a la celda.

Este test pasa sin hacer ningún cambio. Tiene sentido porque el último objeto es que el que sostenemos en la mano. Realmente, lo que nos interesa es saber que el primero ha sido devuelto a la celda.

    def test_player_get_two_objects_and_holds_the_last_one(self):
        first = Thing("Food")
        second = Thing("Sword")
        dungeon = self.dungeon_with_objects(first, second)
        self.player.awake_in(dungeon)
        self.player.do(GetCommand("food"))
        self.player.do(GetCommand("sword"))
        self.assertEqual(second, self.player.holds())

Así que lo modifico para averiguarlo.

    @expect_event_containing("player_got_description", "description", "Food")
    def test_player_get_two_objects_and_holds_the_last_one(self):
        first = Thing("Food")
        second = Thing("Sword")
        dungeon = self.dungeon_with_objects(first, second)
        dungeon.register(self.observer)
        self.player.awake_in(dungeon)
        self.player.do(GetCommand("food"))
        self.player.do(GetCommand("sword"))
        self.player.do(LookCommand("objects"))
        self.assertEqual(second, self.player.holds())

Este test falla como es debido:

AssertionError: 'Food' not found in 'There are no objects\n'

Así que vamos a ver cómo lo podemos implementar. En Player deberíamos tocar aquí:

    def notify(self, event):
        if "player_got_thing" == event.name():
            self._holds = event.thing()
        if "player_collected_thing" == event.name():
            self._backpack.append(event.thing())
            self._notify_observers(BackPackChanged(self._backpack.content()))

La forma más fácil de hacer esto sería implementar un método drop en Dungeon. Teniendo en cuenta que Player tiene una referencia a Dungeon en _receiver es la manera más fácil de hacerlo. No estoy seguro de que sea la más correcta, pero funcionaría.

    def notify(self, event):
        if "player_got_thing" == event.name():
            if self._holds is not None:
                self._receiver.drop(self._holds)
                self._holds = None
            self._holds = event.thing()
        if "player_collected_thing" == event.name():
            self._backpack.append(event.thing())
            self._notify_observers(BackPackChanged(self._backpack.content()))

Y en Dungeon:

    def drop(self, thing):
        self._current_room().put(thing)

El test no pasa y es por algo que pasa en Player. Cuando intentamos aplicar el comando get, Player pone a None self._holds incondicionalmente si no encuentra el objeto deseado en Backpack. Solo debería cambiar el contenido de self._holds si ha encontrado el objeto en la mochila.

    def get(self, thing_name):
        self._holds = self._backpack.get(thing_name)

Y esto se resuelve así:

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

Ahora tendríamos que hacer lo mismo con objetos que tengamos en la mochila. El test coloca (collect) un par de objetos en la mochila primero y luego los coge para usarlos uno por por uno. En la mochila debería quedar Sword y en la mano Food.

    @expect_event_equal("backpack_changed", "content", "Sword")
    def test_player_collects_two_objects_and_holds_the_last_one(self):
        food = Thing("Food")
        sword = Thing("Sword")
        dungeon = self.dungeon_with_objects(food, sword)
        dungeon.register(self.observer)
        self.player.awake_in(dungeon)
        self.player.do(CollectCommand("food"))
        self.player.do(CollectCommand("sword"))
        self.player.do(GetCommand("sword"))
        self.player.do(GetCommand("food"))
        self.assertEqual(food, self.player.holds())

El test falla de la forma esperada, en la mochila solo debe quedar Sword. Esto es lo que nos dice el evento lanzado tras el último CollectCommand, porque lo cierto es que no se ha enviado ninguno después y si miramos el contenido de Backpack usando el depurador, vemos que está vacía.

Expected :Sword
Actual   :Food, Sword

Me gustaría contar con un test más directo de Backpack porque de hecho el test es un poco engañoso, pero con los eventos debería ser suficiente.

La cuestión clave en este caso es que deberíamos lanzar un evento backpack_changed cuando quitamos un objeto de la mochila. Si aplicamos este cambio el test fallará de una manera diferente.

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

Así es como falla el test, y describe mucho mejor lo que necesitamos:

Expected :Sword
Actual   :

Ahora el test dice que no queda nada en la mochila y eso es exactamente lo que necesitábamos, porque el comportamiento actual es que al coger algo nuevo de la mochila machaca lo que tengamos en la mano.

Lo que queda ahora, entonces, es comprobar si tenemos algo en la mano y ponerlo en la mochila y entonces coger el objeto deseado:

    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()))

Y con esto pasamos el test.

Tal como está ahora lo que ocurre es que “soltamos” el objeto que teníamos en la mano en el lugar de donde cogemos el nuevo. Si es en la celda, lo soltamos en la celda. Si lo sacamos de la mochila, ponemos el anterior en la mochila. Por el momento me parece una buena solución.

Próximos pasos

Quedan todavía partes de esta iteración por desarrollar, como son añadir nuevos objetos en la mazmorra e introducir una variante de Exit que requiera una llave específica para salir. Pero como el artículo se alarga, voy a dejarlo aquí.

Antes de terminar sí que me gustaría hacer algunos refactors básicos en sitios diversos.

Por ejemplo:

class Player:
    
    # Removed code
    
    def notify(self, event):
        if "player_got_thing" == event.name():
            self._do_get_thing(event)
        if "player_collected_thing" == event.name():
            self.do_collect_thing(event)

    def do_collect_thing(self, event):
        self._backpack.append(event.thing())
        self._notify_observers(BackPackChanged(self._backpack.content()))

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

Otra cosa que voy a hacer es introducir una clase base para los eventos. Hasta ahora no me ha hecho falta, pero me gustaría depender menos del nombre como string.

class Event:
    def name(self):
        pass

    def of_type(self, cls):
        return isinstance(self, cls)


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

    def name(self):
        return "player_died"

El método of_type, me permite hacer cosas como esta:

class Player:
    
    # Removed code
    
    def notify(self, event):
        if event.of_type(PlayerGotThing):
            self._do_get_thing(event)
        if event.of_type(PlayerCollectedThing):
            self.do_collect_thing(event)

Con este cambio, la gestión de eventos es más sólida y debería ser menos ambigua. Tengo que cambiarlo en todos los lugares, y también en los decoradores de los tests.

Seguimos en la siguiente entrada

December 20, 2022

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