Mastodon

Dungeon 11. Power-ups y un poco de orden

por Fran Iglesias

Ya son horas de empezar a añadir prestaciones nuevas, pero antes deberíamos terminar de poner un poco de orden en algunas partes del código.

Más eventos

En el capítulo anterior señalaba que tendría sentido lanzar el evento PlayerMoved desde Door, lo que nos ayudaría a reducir el uso de ActionResult, que quedaría limitado básicamente a comunicar algunos mensajes… y no todos.

    def go(self, direction):
        result = self._current_room().go(Dir(direction))
        if result.get("destination") is not None:
            self._current = result.get("destination")
            self._notify_observers(PlayerMoved(self._current))
        return result

En cualquier caso, Dungeon se convertiría también en observer, lo que puede quedar un poco raro dado que ahora mismo también actúa como Subject. Es decir, tiene que escuchar el evento PlayerMoved para actualizar la referencia a la habitación actual que se guarda en Dungeon._current.

Creo que ahora mismo los tests existentes garantizarían que el cambio podría funcionar, así que vamos a hacerlo directamente.

class Door(Wall):
    def __init__(self, destination):
        self._destination = destination
        self._subject = Subject()

    def go(self):
        self._notify_observers(PlayerMoved(self._destination))
        message = "You move to room '{dest}'".format(dest=self._destination)
        return ActionResult.player_moved(message, self._destination)

    def look(self):
        return ActionResult.player_acted("There is a door")

    def register(self, observer):
        self._subject.register(observer)

    def _notify_observers(self, event):
        self._subject.notify_observers(event)

Y Dungeon:

class Dungeon:
    def __init__(self, rooms):
        self._rooms = rooms
        self._current = 'start'
        self._subject = Subject()

    def go(self, direction):
        result = self._current_room().go(Dir(direction))
        return result

    # Removed code

    def register(self, observer):
        self._subject.register(observer)
        self._rooms.register(observer)
        self._rooms.register(self)

    def notify(self, event):
        if event.name() == "player_moved":
            self._current = event.room()

Con estos cambios hay un par de tests que fallan porque dependen de que ActionResult contenga el resultado adecuado, cosa que ahora no ocurre. La cuestión es ver si los podemos testear de otra forma: esperando que se hayan lanzado los eventos adecuados:

class DungeonAsSubjectTestCase(unittest.TestCase):
    def test_supports_current_room_changed_event(self):
        fake_observer = FakeObserver()

        builder = DungeonBuilder()
        builder.add('start')
        builder.add('other')
        builder.connect('start', Dir.N, 'other')
        dungeon = builder.build()

        dungeon.register(fake_observer)

        dungeon.go(Dir.N)

        self.assertTrue(fake_observer.is_aware_of("player_moved"))

    def test_supports_player_exited_event(self):
        fake_observer = FakeObserver()

        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())
        dungeon = builder.build()

        dungeon.register(fake_observer)

        dungeon.go(Dir.N)

        self.assertTrue(fake_observer.is_aware_of("player_exited"))

Nos queda revisar un pequeño problema. Se trata de evitar que un mismo observer se suscriba dos veces al mismo subject, como podría ocurrir fácilmente con Dungeon. Puede hacerse así:

class Subject:
    def __init__(self):
        self._observers = []

    def register(self, observer):
        if observer in self._observers:
            return
            
        self._observers.append(observer)

    def notify_observers(self, event):
        for observer in self._observers:
            observer.notify(event)

Con todos estos cambios, hay varias situaciones en las que ActionResult ya no es muy útil. Por ejemplo, podemos trasladar la creación de algunos mensajes directamente a Printer:

class Printer:
    def __init__(self, show_output):
        self.show_output = show_output
        self._command = ""
        self._description = ""
        self._energy = ""
        self._title = ""
        
    def notify(self, event):
        if event.name() == "player_energy_changed":
            self._energy = str(event.energy().value())
        elif event.name() == "player_got_description":
            self._description = event.description()
        elif event.name() == "player_moved":
            self._title = event.room()
            self._description = "You moved to room '{dest}'".format(dest=event.room())
        elif event.name() == "player_sent_command":
            self._command = "{} {}".format(event.command(), event.argument())
    
    # Removed code

De todos modos, ActionResult sigue siendo difícil de extraer, aunque ya solo le quedan dos usos: mover descripciones de lo que la jugadora puede ver y el coste de los comandos.

Esto último me interesa porque ya es hora de volver a iterar en el juego con un tema relacionado.

Recuperar energía

Que se pierda energía a medida que se avanza en el juego actúa como motivación para intentar salir cuanto antes de la mazmorra. Hace más interesante el juego por cuanto introduce un riego de salir a tiempo. Sin embargo, a medida que crezca la complejidad de la mazmorra y se introduzcan desafíos y puzzles, hará falta una forma de recuperar energía.

Para ello, en algunas habitaciones podría haber comida, de tal forma que la jugadora pueda cogerla y usarla para recuperarse.

Esto tiene varias implicaciones:

  • Tiene que haber un objeto que represente la comida (Food) y que aporte cierta cantidad de energía.
  • Hay un límite a la cantidad máxima de energía que puede tener la jugadora.
  • Tiene que haber una acción que permita a la jugadora coger la comida y comerla (collect food, eat food).
  • La jugadora podría recolectar la comida, pero comerla en otro momento. Esto implicaría tener algún tipo de almacenaje que lleva consigo (Bag) que podría tener una capacidad limitada.

Llenar de cosas la mazmorra

Todo esto presenta algunos desafíos.

El primero es que tenemos que poder añadir objetos a una habitación y que la jugadora pueda verlos cuando mira alrededor.

Veamos el test de Room en su situación actual:

class TestRoom(TestCase):
    def setUp(self):
        walls = Walls()
        walls.set(Dir.N, Exit())
        self.room = Room(walls)

    def test_wall_in_all_directions(self):
        result = self.room.go(Dir.N)
        self.assertEqual("Congrats. You're out", result.get("message"))
        result = self.room.go(Dir.E)
        self.assertEqual('You hit a wall', result.get("message"))
        result = self.room.go(Dir.S)
        self.assertEqual('You hit a wall', result.get("message"))
        result = self.room.go(Dir.W)
        self.assertEqual('You hit a wall', result.get("message"))

    def test_can_provide_description(self):
        description = """North: There is a door
East: There is a wall
South: There is a wall
West: There is a wall
That's all
"""

        result = self.room.look('around')

        self.assertEqual(description, result.get("message"))

Todavía dependemos de ActionResult, que está acoplando todo con todo. En el método look tiene sentido que devuelva un objeto con una descripción de lo que se ve. Pero en el método go, no está tan claro que sea una buena idea. Quizá Wall, pueda lanzar un evento “player_hit_wall” en su lugar.

En todo caso, ahora estamos interesadas en el método look y en obtener una descripción que muestre una lista de objetos disponibles en la habitación.

Al comando look le podemos pasar un argumento. Podríamos usar esto para limitar la descripción. Para empezar, como no hay objetos, nos dice que no ve nada:

    def test_can_provide_description_of_objects_in_empty_room(self):
        description = "There are no objects"
        result = self.room.look('objects')
        self.assertEqual(description, result.get("message"))

Suficiente para introducir un mínimo de código.

class Room:
    def __init__(self, walls):
        self._walls = walls

    def go(self, direction):
        wall = self._walls.get(direction)
        return wall.go()

    def look(self, argument):
        if argument == "objects":
            return ActionResult.player_acted("There are no objects")
        response = ""
        response += "North: " + self._walls.get(Dir.N).look().get("message") + "\n"
        response += "East: " + self._walls.get(Dir.E).look().get("message") + "\n"
        response += "South: " + self._walls.get(Dir.S).look().get("message") + "\n"
        response += "West: " + self._walls.get(Dir.W).look().get("message") + "\n"

        response += "That's all" + "\n"
        return ActionResult.player_acted(response)

    def register(self, observer):
        self._walls.register(observer)

Ahora hay que refactorizar con los tests en verde para tener algo más manejable:

    def look(self, argument):
        if argument == "objects":
            return self._look_objects()
        return self._look_around()

    def _look_objects(self):
        return ActionResult.player_acted("There are no objects")

    def _look_around(self):
        response = ""
        response += "North: " + self._walls.get(Dir.N).look().get("message") + "\n"
        response += "East: " + self._walls.get(Dir.E).look().get("message") + "\n"
        response += "South: " + self._walls.get(Dir.S).look().get("message") + "\n"
        response += "West: " + self._walls.get(Dir.W).look().get("message") + "\n"
        response += "That's all" + "\n"
        return ActionResult.player_acted(response)

No me emociona el código que tenemos en _look_around, así que aprovechemos para arreglarlo un poco. Primero lo muevo todo a _walls.

    def _look_around(self):
        response = self._walls.look()
        return ActionResult.player_acted(response)

El método look proporciona una descripción de los objetos a los que se llama con él.

En Walls es posible hacer esto:

    def look(self):
        response = ""
        response += str(Dir.N.value).capitalize() + ": " + self._walls[Dir.N].look().get("message") + "\n"
        response += str(Dir.E.value).capitalize() + ": " + self._walls[Dir.E].look().get("message") + "\n"
        response += str(Dir.S.value).capitalize() + ": " + self._walls[Dir.S].look().get("message") + "\n"
        response += str(Dir.W.value).capitalize() + ": " + self._walls[Dir.W].look().get("message") + "\n"
        response += "That's all" + "\n"
        return response

Y luego, un bucle así:

    def look(self):
        response = ""
        for dirs in Dir:
            response += str(dirs.value).capitalize() + ": " + self._walls[dirs].look().get("message") + "\n"

        response += "That's all" + "\n"
        return response

Siempre y cuando hagamos un pequeño cambio en el orden de Dir, que nos podemos permitir:

class Dir(Enum):
    N = "north"
    E = "east"
    S = "south"
    W = "west"

Una vez limpio esto, vamos a ver cómo implementar tener objetos en la habitación. Se me ocurre usar un método put en room al que se le pasa el objeto que queremos añadir. Sabremos que está bien porque lo vemos en la descripción.

    def test_can_put_objects_in_a_room(self):
        description = """There are:
* Food
* Wood Sword
* Gold Coin
"""
        self.room.put(Thing("Food"))
        self.room.put(Thing("Wood Sword"))
        self.room.put(Thing("Gold Coin"))
        result = self.room.look('objects')
        self.assertEqual(description, result.get("message"))

Introduzco un objeto Things para guardar las Thing y obtengo esto:

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

    def put(self, a_thing):
        self._things[a_thing.name()] = 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())
        else:
            response = "There are no objects"
        return response
class Room:
    def __init__(self, walls):
        self._walls = walls
        self._things = Things()

    # Removed code
    
    def _look_objects(self):
        response = self._things.look()
        return ActionResult.player_acted(response)

    # Removed code

    def put(self, an_object):
        self._things.put(an_object)

Con este código ya podemos poner objetos en las celdas de la mazmorra. Ahora necesitamos usarlo en DungeonBuilder para poder diseñar mazmorras que contengan objetos.

class TestDungeonBuilder(TestCase):

    # Removed code

    def test_can_put_things_in_rooms(self):
        builder = DungeonBuilder()
        builder.add('101')
        builder.add('start')
        builder.connect('start', Dir.N, '101')
        builder.put('101', Thing("Sword"))
        builder.set('101', Dir.E, Exit())

        dungeon = builder.build()

        dungeon.go('north')
        response = dungeon.look('objects')

        self.assertIn("Sword", response.get("message"))

El Builder quedaría así:

class DungeonBuilder:
    def __init__(self):
        self._names = []
        self._walls = dict()
        self._things = dict()

    def build(self):
        rooms = Rooms()
        for name in self._names:
            walls = Walls()
            for direction, wall in self._walls[name].items():
                walls = walls.set(direction, wall)

            room = Room(walls)
            for thing in self._things[name]:
                room.put(thing)
            rooms = rooms.set(name, room)

        return Dungeon(rooms)

    def add(self, room_name):
        self._names.append(room_name)
        self._walls[room_name] = dict()
        self._things[room_name] = []

    def set(self, room_name, direction, wall):
        self._walls[room_name][direction] = wall

    def connect(self, origin, direction, target):
        self.set(origin, direction, Door(target))
        self.set(target, direction.opposite(), Door(origin))

    def put(self, room_name, thing):
        self._things[room_name].append(thing)

Y además he descubierto que Dungeon debería registrarse como observer de sus habitaciones nada más construirse:

class Dungeon:
    def __init__(self, rooms):
        self._rooms = rooms
        self._current = 'start'
        self._subject = Subject()
        self._rooms.register(self)

    # Removed code

Y con esto resolvemos la parte de poder disponer objetos (Thing) en las celdas de la mazmorra. De momento, empezaré con algo muy simple, a la espera de descubrir qué comportamientos necesitamos.

Para celebrar este logro, voy a introducir algunos objetos en la mazmorra “game”.

    def _build_dungeon(self):
        builder = DungeonBuilder()
        for cell in range(23):
            builder.add(str(cell))
        builder.add('start')
        builder.add('exit')

        builder.connect('0', Dir.S, '5')
        builder.connect('1', Dir.E, '2')
        builder.connect('2', Dir.E, '3')
        builder.connect('2', Dir.S, '7')
        builder.connect('3', Dir.E, '4')
        builder.connect('4', Dir.S, '9')
        builder.connect('5', Dir.S, '10')
        builder.connect('6', Dir.S, '11')
        builder.connect('6', Dir.E, '7')
        builder.connect('8', Dir.E, '9')
        builder.connect('8', Dir.S, '13')
        builder.connect('9', Dir.S, 'exit')
        builder.connect('10', Dir.E, '11')
        builder.connect('11', Dir.E, '12')
        builder.connect('11', Dir.S, '15')
        builder.connect('13', Dir.S, '17')
        builder.connect('14', Dir.S, '19')
        builder.connect('15', Dir.S, 'start')
        builder.connect('16', Dir.E, '17')
        builder.connect('17', Dir.E, '18')
        builder.connect('19', Dir.E, 'start')
        builder.connect('start', Dir.E, '20')
        builder.connect('start', Dir.E, '20')
        builder.connect('21', Dir.E, '22')

        builder.set('exit', Dir.E, Exit())
        builder.put('10', Thing("Food"))
        builder.put('17', Thing("Sword"))
        builder.put('1', Thing("Food"))
        return builder.build()

Por ejemplo:

Remaining energy: 84
======================================
What should I do? >look objects
You said: look objects

10
--------------------------------------
There are:
* Food

Remaining energy: 83
======================================

Sería conveniente hacer que look around permitiese ver no solo las paredes, sino también la lista de objetos que existe en la sala. Para hacer nos conviene retocar un poco los tests, de tal modo que no dependan tanto del resultado exacto que se muestra.

Hacemos un commit con todos estos cambios.

Poder recoger cosas

Ahora que ya nos podemos encontrar objetos en nuestros recorridos por la mazmorra queremos poder recogerlos y coleccionarlos o usarlos.

Aquí habría que distinguir un matiz entre coleccionar objetos y cogerlos:

  • Coleccionar se refiere a coger un objeto y guardarlo en una bolsa o mochila para usar en otro momento. Se puede coleccionar un número limitado de objetos. Esto podría representarse, por ejemplo, con el comando collect thing. Siempre se refiere a un objeto que está en la mazmorra y que se pondrá en nuestra bolsa.
  • Coger se refiere a coger y sostener un objeto para poder usarlo. Diremos que este objeto lo tenemos en la mano. Solo se puede utilizar un objeto a la vez. En este caso, el comando sería get thing. Este comando puede actuar sobre objetos que están en la mazmorra o que están e nuestra bolsa.

También necesitaremos un comando use thing. Este comando podría aplicarse a todo objeto sobre el que se pueda hacer get o bien sobre el objeto que tenemos ahora mismo en la mano. Usar un objeto nos plantea una cuestión: hay objetos que podremos usar varias veces y objetos de un solo uso. Por ejemplo, la comida se puede usar una vez. Se consume y desaparece. Una espada, por ejemplo, podríamos llevarla con nosotras todo el tiempo que nos parezca.

Además, también se requiere una forma de soltar un objeto. De nuevo, tenemos dos posibles significados o contextos:

  • Dejar de tener sujeta una cosa para que vuelva a su sitio. Este comando podría ser drop y, para simplificar, podría poner el objeto en la bolsa.
  • Si queremos deshacernos de un objeto definitivamente sería el comando leave, que lo deja en la habitación de la mazmorra en la que nos encontremos.

Como se puede ver, se plantean un montón de cosas aquí, por lo que nos conviene seguir dividiendo esta historia hasta reducirla a un incremento más manejable.

Mi propuesta ahora mismo es implementar get y use en primer lugar, que es lo que necesito para poder usar objetos que proporcionen energía. Como acabamos de decir, los comandos get y use pueden aplicarse sobre los mismos objetos si están disponibles.

Necesitaremos un GetCommand cuyo argumento nos indicará qué objeto coger y el cual buscará en la mazmorra después de descartar que no lo tenga ya en la mano. Si no hay ninguno que encaje, no tendrá efectos.

En todo caso, necesitamos que Player tenga una mano que, inicialmente estará vacía y que puede contener un objeto. Este objeto será el destinatario del comando use en su momento.

Preparemos un escenario de test para desarrollarlo. En este escenario vamos a empezar asegurando que cuando cogemos un objeto este ya no está en la habitación. El test nos requerirá escribir el comando GetCommand y al menos un método en Dungeon y Room para extraer el objeto indicado.

class PlayerGettingThingsTestCase(unittest.TestCase):
    def test_player_get_object_removes_from_room(self):
        player = Player.awake()
        dungeon = self.dungeon_with_object()
        player.awake_in(dungeon)
        get_command = GetCommand("Food")
        player.do(get_command)
        description = dungeon.look('objects')
        self.assertIn("There are no objects", description.get("message"))
        
    def dungeon_with_object(self):
        builder = DungeonBuilder()
        builder.add('start')
        builder.put('start', Thing("Food"))
        return builder.build() 

Este sería GetCommand en una primera implementación:

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

    def do(self, receiver):
        result = receiver.get(self._argument)
        result.set('cost', EnergyUnit(1))
        return result

    def name(self):
        return "get"

Dungeon básicamente tiene que delegar en la habitación actual este comando, así que tenemos que implementar en Room, algo que recupere el objeto deseado a través de su nombre. Algo así:

class Room:
    def __init__(self, walls):
        self._walls = walls
        self._things = Things()

    # Removed code

    def get(self, thing_name):
        return self._things.get(thing_name)

Pero aquí nos topamos con los condicionantes de estar usando ActionResult, así que vamos a salir del paso cambiando un poco el comando GetCommand, de modo que el test avance.

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

    def do(self, receiver):
        receiver.get(self._argument)
        result = ActionResult("Got thing")
        result.set('cost', EnergyUnit(1))
        return result

    def name(self):
        return "get"

Lo que no hemos hecho ha sido retirar el objeto de la lista así que el test falla porque espera que la habitación esté vacía de cosas. Lo podemos arreglar modificando Things:

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

    # Removed code

    def get(self, thing_name):
        return self._things.pop(thing_name)

Esto ha hecho pasar el test. El problema interesante viene ahora… ¿Cómo hacemos llegar este objeto a Player? Devolverlo directamente hace que en el método do tengamos que saber que algunos comandos devuelven cosas que tenemos que guardar y además queremos deshacernos o minimizar el uso de ActionResult.

La respuesta es… eventos, eventos everywhere.

Y es que los eventos se pueden aplicar a todo lo que ocurre en el juego para permitir que objetos dispares puedan comunicarse. En este caso, cuando la jugadora coja algo de una habitación de la mazmorra, en lugar de pasarlo de un objeto a otro hasta llegar la propia jugadora, lo que haremos es que esta se suscriba al evento player_got_thing, en el cual vendrá la cosa que ha cogido, de modo que pueda actualizar su estado sin forzar a ningún objeto intermediario a conocer nada de esta interacción.

Para desarrollar esto vamos a introducir un nuevo test y haré que Player exponga un método para saber qué tiene en la mano. Puede que este método tenga una vida efímera, pero así podremos avanzar en el desarrollo.

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

He aquí el nuevo evento:

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

    def name(self):
        return "player_got_thing"

    def thing(self):
        return self._thing

Y los cambios en Player para poder manejarlo:

class Player:
    def __init__(self, starting_energy):
        self._energy = Energy(starting_energy)
        self._last_result = ActionResult.player_acted("I'm ready")
        self._subject = Subject()
        self._receiver = None
        self._holds = None

    # Removed code

    def awake_in(self, dungeon):
        dungeon.register(self)
        self._receiver = dungeon

    def do(self, command):
        self._execute_command(command, self._receiver)
        self._update_energy()

    def holds(self):
        return self._holds

    # Removed code
    
    def notify(self, event):
        if "player_got_thing" == event.name():
            self._holds = event.thing()

Y en Dungeon para comunicarlo.

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

    # Removed code

    def register(self, observer):
        self._subject.register(observer)
        self._rooms.register(observer)

    # Removed code

Incluyo una corrección sobre cómo se estaban registrando los observers, ya que Dungeon se estaba registrando a sí misma.

Finalmente, los test pasan, de modo que la jugadora ya puede coger objetos. Lo siguiente que necesitamos es que pueda usarlos. Pero antes de eso, sería bueno tener una forma de tener feedback del resultado de la acción. Por supuesto, Printer puede atender al nuevo evento.

class Printer:
    def __init__(self, show_output):
        self.show_output = show_output
        self._command = ""
        self._description = ""
        self._energy = ""
        self._title = ""

    def notify(self, event):
        if event.name() == "player_energy_changed":
            self._energy = str(event.energy().value())
        elif event.name() == "player_got_description":
            self._description = event.description()
        elif event.name() == "player_moved":
            self._title = event.room()
            self._description = "You moved to room '{dest}'".format(dest=event.room())
        elif event.name() == "player_sent_command":
            self._command = "{} {}".format(event.command(), event.argument())
        elif event.name() == "player_exited":
            self._description = "Congrats. You're out"
        elif event.name() == "player_got_thing":
            self._description = "You've got {}".format(event.thing().name())

Con estos cambios, pruebo el juego, pero descubro un par de malas noticias.

La primera es que he olvidado que tenemos una CommandFactory que se encarga de obtener el objeto Command a partir del input:

class CommandFactory:

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

        if command == "go":
            return GoCommand(argument)
        if command == "look":
            return LookCommand(argument)
        if command == "get":
            return GetCommand(argument)

        return InvalidCommand(user_input)

La segunda es que el juego falla… porque al pedir get Food el argumento se traduce a food en ConsoleObtainUserCommand que normaliza todas las entradas a lower case. Por esa razón, el programa no puede encontrar el objeto deseado, puesto que el nombre puede llevar otro caso. Pese a la buena cobertura de tests que tenemos, ninguno de ellos revela este problema.

Precisamente, si revisamos los últimos tests que hemos escrito se puede observar. La factoría siempre creará los comandos con los argumentos en lower case, mientras que en el test lo pasamos capitalized, dado que así es como ponemos el nombre a los objetos. Este nombre actúa como identificador del objeto, así que en realidad diría que el problema lo tenemos en la forma en que lo registramos.

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

Pero ¿cómo mantener la consistencia en todos los posibles usos? Podríamos introducir validaciones en los objetos Command, pero también tenemos que garantizar que los identificadores bajo los que guardamos los objetos en las colecciones Things son correctos.

Este problema viene del smell primitive obsession. Estamos usando tipos primitivos y, por tanto, no podemos garantizar que cumplan ciertas reglas no escritas en nuestro código. Por ejemplo, la de que estos identificadores deberían ser siempre lower case.

Una solución fácil de aplicar, antes de intentar atacar la primitive obsession sería hacer que Things fuerce esta regla. Para poner de manifiesto el problema, podemos hacer un simple cambio en los tests relacionados con coger objetos para hacerlos fallar.

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

Y esto no es otra cosa que un ejemplo de mutation testing: introducir pequeños cambios en los tests para ver si fallan y desvelan casos que no estamos contemplando o que hemos implementado de una forma incompleta.

De hecho, el test ahora falla, revelando el problema subyacente. La solución:

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

    def put(self, a_thing):
        self._things[a_thing.name().lower()] = 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())
        else:
            response = "There are no objects\n"
        return response

Con esto hemos resuelto el problema. Pero nos damos cuenta de otra cosa, que solamente se observa cuando ejecutamos el juego: si distintos eventos modifican un mismo campo en Printer se machacan entre sí, prevaleciendo el último.

Se puede ver aquí:

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

10
--------------------------------------
Got thing
Remaining energy: 83
======================================
What should I do? >

Got thing es el mensaje que se pasa mediante ActionResult como respuesta del comando GetCommand. Además, este mensaje es pasado en algún momento como evento, por lo que acaba “comiéndose” al evento real.

Tenemos dos problemas. En primer lugar, esta redundancia en los eventos que tendremos que arreglar revisando el papel del ActionResult y lo que hacemos con él. En segundo, como gestionar que distintos mensajes puedan ocurrir en el mismo ciclo de juego para acumularlos en lugar de machacarse mutuamente.

Estos dos problemas los voy a posponer. Antes quiero completar la iteración: una vez que podemos tener comida en la mano, hay que poder comerla.

Usar objetos

Hemos quedado que para usar un objeto tiene que estar en nuestra mano, lo que ocurre tras un comando get. Para utilizar un objeto emplearemos el nuevo comando use. El receiver natural de este sería un objeto de tipo Thing.

Pero Player tiene a Dungeon como único receiver, por lo que tendremos que hacer cambios nos permitan tener varios receivers. Nos interesa un patrón cadena de responsabilidad, ya que al recibir el comando que sea, no sabemos exactamente qué objeto será su receiver real.

De hecho, en nuestro juego hay comandos como look que pueden ser respondidos por todos los objetos, mientras que otros solo pueden ser aplicados por alguno específico. Aparte, todos los objetos serían capaces de pasar los comandos a otros objetos independientemente de que sean capaces de ejecutarlos o no.

Por ejemplo, Dungeon pasa comandos a la Room actual, que a su vez los pasa a sus Walls. En algún momento alguien responde.

Aquí tenemos un buen percal, puesto que cada Command ejecuta un método específico en el receiver. Esto funcionaba bien hasta ahora, porque el único receiver es Dungeon y delega cuando es necesario. Si bien, esto funciona, tiene limitaciones y problemas. Por ejemplo, Dungeon tiene que tener definidos métodos para todos los comandos que podría llegar a recibir, aunque realmente no maneja ninguno, sino que se invoca ese comando en la habitación actual.

Una posibilidad sería que cualquier objeto que pueda recibir comandos exponga un método do o execute. Este método comprobaría si puede ejecutar o no el comando. Si es que sí, invoca el método correspondiente, si es que no, delega en otro objeto.

Pero, ¿qué podríamos hacer temporalmente para sacar la feature de usar objetos? Recordemos que únicamente tenemos un objeto con el que lidiar y que solo interesa a un posible receiver (Player), sobre el que tiene un efecto muy definido.

Lo primero, hagamos un test, que ha resultado un pelín largo tras algunas correcciones:

class PlayerUsingFoodTestCase(unittest.TestCase):
    def test_using_food_makes_player_increase_energy(self):
        thing = Thing("Food")
        fake_observer = FakeObserver()
        dungeon = self.dungeon_with_object(thing)

        player = Player.awake_with_energy(EnergyUnit(50))
        player.awake_in(dungeon)
        player.register(fake_observer)

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

        last_energy_event = fake_observer.last("player_energy_changed")
        self.assertEqual(58, last_energy_event.energy().value())
        self.assertIsNone(player.holds())

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

En pocas palabras, el test comienza definiendo una jugadora que recoge y usa un objeto de comida. Lo que esperamos es que su energía cambie: empieza con 50 se descuenta el gasto de los dos comandos (una unidad cada uno) y se añaden diez unidades de haber consumido la comida. El resultado sería 58.

Además, se espera que este objeto se consuma en su totalidad, por lo que en la mano no quedará nada.

Empezamos definiendo UseCommand:

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

    def name(self):
        return "use"

    def do(self, receiver):
        receiver.use(self._argument)

Ahora ejecutamos el test y vemos que falla porque Dungeon no tiene el método use. Obviamente no está implementado, pero además de momento solo queremos que este comando lo ejecute Player, así que vamos a introducir código en este objeto pero solo para ese caso:

class Player:
    
    # Removed code
    
    def _execute_command(self, command, receiver):
        if command.name() == "use":
            command.do(self)
            return
        self._last_result = command.do(receiver)
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
        self._notify_observers(PlayerGotDescription(self._last_result.get('message')))

Ahora ya falla porque es Player quien no tiene un método use. En este método básicamente comprobamos que el objeto que tenemos en la mano es el indicado por el comando. Si no es así, de momento no hacemos nada, más adelante pondremos un evento que señale que no se puede usar el objeto:

    def use(self, thing_name):
        if self._holds is not None and self._holds.name() != thing_name:
            return

El siguiente paso es bastante obvio: Player tiene que usar el objeto. Lo que no sabemos es la forma específica en que esto ha de ocurrir y que debería definir el propio objeto. Para eso, podemos usar un double dispatch:

    def use(self, thing_name):
        if self._holds is not None and self._holds.name() != thing_name:
            return
        self._holds.apply_on(self)

Es decir, un objeto se pasa a otro objeto, para que este último lo utilice según sea adecuado.

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

Y supongo que estás pensando que no todos los objetos Thing van a provocar ese efecto allá donde se apliquen. Esto es una consideración importante porque querremos tener objetos que causen distintos efectos cuando se usan, pero no la vamos a atacar ahora. De momento, queremos que funcione en este único caso, lo que sentará las bases del desarrollo futuro.

Así que, volviendo a Player:

class Player:
    
    # Removed code

    def use(self, thing_name):
        if self._holds is not None and self._holds.name() != thing_name:
            return
        self._holds.apply_on(self)
        self._holds = None

    def increase_energy(self, delta_energy):
        self._energy.increase(delta_energy)

Si vamos ejecutando el test nos daremos cuenta de que la energía no ha cambiado y esto tiene una explicación sencilla. Es debido al problema del caso del identificador de los objetos, que no hemos tenido en cuenta. Así que, de momento, voy a hacer un rápido parche para que el test pase:

    def use(self, thing_name):
        if self._holds is not None and self._holds.name().lower() != thing_name.lower():
            return
        self._holds.apply_on(self)
        self._holds = None

    def increase_energy(self, delta_energy):
        self._energy.increase(delta_energy)

¡Buen provecho!

No olvidemos registrar el nuevo comando en la factoría:

class CommandFactory:

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

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

Con este código, la jugadora ya se come la comida, lo que le proporciona energía. Este objeto no se puede volver a utilizar porque se consume todo, así que desaparece.

¿Limitaciones? Muchas. El objeto Thing tiene ahora un comportamiento muy específico, que no es válido para otros tipos de objetos que podamos disponer en la mazmorra. Imagínate una espada, una llave o una piedra. Lo interesante de esta primera implementación es que ya va revelando los rasgos que tendremos que tener en cuenta en esta familia de objetos:

  • Se usan sobre otro objeto. Los objetos de comida se aplican sobre Player, pero una espada podría emplearse sobre un enemigo, mientras que una llave podría abrir una puerta.
  • Pueden consumirse totalmente o pueden usarse repetidas veces.

Lo anterior me sugiere que tendremos objetos Thing con especializaciones. Ya volveremos a eso.

En cualquier caso, es momento de refactorizar la solución y completar algunos aspectos, como reaccionar adecuadamente cuando no se tiene un objeto en la mano sobre el que actuar, o cuando el objeto no se puede aplicar sobre Player. Por lo menos tenemos que generar un feedback para que la jugadora sepa qué ha pasado.

Y puesto que el juego funciona, pese a que todavía tiene algunos inconvenientes, consolidamos los cambios.

Estos inconvenientes de los que hablamos están relacionados, sobre todo, con la falta de feedback, o al menos que este no sea lo bastante claro:

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

10
--------------------------------------
Got thing
Remaining energy: 83
======================================
What should I do? >use food
You said: get food

10
--------------------------------------
Got thing
Remaining energy: 92
======================================

Aquí podemos ver que tras introducir el comando use food se indica que hemos dicho get food que es incorrecto. Además, aparece la frase genérica Got thing en ambos casos, cuando debería indicarnos qué ha pasado (Got food y Used food). Por otro lado, aunque se aprecia el cambio en el nivel de energía, no se explica el efecto.

Todo esto tiene su raíz en la interferencia entre ActionResult comunicando los efectos de las acciones y los eventos y Printer no sabiendo manejarlo.

En fin. Hay muchos temas en el aire y me gustaría abordarlos uno a uno, pero también necesito dedicar un tiempo a limpiar cosas y ordenar los últimos cambios en el código. Poco a poco, se va generando un montón de basurilla que empieza a complicar las cosas otra vez.

Introduciendo solidez en el uso de objetos

Para empezar, quiero terminar de implementar bien UseCommand en el sentido de gestionar los diversos sad paths. Es decir, qué pasa si realmente no puedo usar el objeto o no tengo objeto que usar.

Añado un test para este último caso en el que Player no ha cogido ningún objeto antes. Podría lanzar un evento genérico action_not_completed que contenga la razón. Además de eso, verificamos que no hay otros cambios significativos.

    def test_trying_to_use_an_object_but_holding_none(self):
        fake_observer = FakeObserver()
        dungeon = self.dungeon_with_object(Thing("Food"))

        player = Player.awake_with_energy(EnergyUnit(50))
        player.awake_in(dungeon)
        player.register(fake_observer)

        player.do(UseCommand("food"))

        self.assertTrue(fake_observer.is_aware_of("action_not_completed"))
        last_energy_event = fake_observer.last("player_energy_changed")
        self.assertEqual(49, last_energy_event.energy().value())
        self.assertIsNone(player.holds())

Como era de esperar, el test se rompe porque al no tener objeto en la mano no podemos enviarle ningún mensaje. Tenemos que prevenir esto:

class Player:
    # Removed code

    def use(self, thing_name):
        if self._holds is None:
            return
        if self._holds is not None and self._holds.name().lower() != thing_name.lower():
            return
        self._holds.apply_on(self)
        self._holds = None

El siguiente problema es que Player espera un ActionResult para obtener el coste de ejecutar un comando. UseCommand no devuelve nada y no quiero que devuelva nada precisamente porque quiero sacar a ActionResult de ahí.

Lo que me planteo es que esto se pueda hacer de otra manera, de forma que los Command puedan informar a Player del coste, al menos de momento. Pero intentaré hacer convivir las dos ideas mientras hago estos cambios.

Primero añado el método público Cost a Command.

class Command:

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

    def do(self, receiver):
        pass

    def name(self):
        return None
    
    def cost(self):
        return EnergyUnit(1)

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

        return ""

También añado el evento:

class ActionNotCompleted:
    def __init__(self, reason):
        self._reason = reason

    def name(self):
        return "action_not_completed"

    def reason(self):
        return self._reason

Y completo Player con los siguientes cambios:

class Player:
    def __init__(self, starting_energy):
        self._energy = Energy(starting_energy)
        self._last_result = ActionResult.player_acted("I'm ready")
        self._subject = Subject()
        self._receiver = None
        self._holds = None
        self._last_command = None

    def _execute_command(self, command, receiver):
        self._last_result = None
        if command.name() == "use":
            command.do(self)
            self._last_command = command
            return
        self._last_result = command.do(receiver)
        self._last_command = command
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
        self._notify_observers(PlayerGotDescription(self._last_result.get('message')))

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

    # Removed code

    def _last_action_cost(self):
        if self._last_result is not None:
            return self._last_result.get("cost")
        if self._last_command is not None:
            if hasattr(self._last_command, "cost"):
                return self._last_command.cost()

    # Removed code

Y con esto pasa el test. Tenemos que hacer que Printer sea capaz de manejar este evento porque si jugamos, aunque no se rompe nada, tampoco se nos explica nada. Es un poco WTF:

What should I do? >use food
You said: go north

15
--------------------------------------

Remaining energy: 94
======================================

De hecho, es que no sabemos si tenemos algo en la mano o no, y sería bueno que la pantalla nos diese ese feedback.

El caso es que, pensándolo, me doy cuenta de que hay situaciones en las que un campo tiene que reiniciarse y otras en las que el campo tiene que acumular información. Eso es algo que ahora no se contempla. Pero primero, el evento:

class Printer:
    def __init__(self, show_output):
        self.show_output = show_output
        self._command = ""
        self._description = ""
        self._energy = ""
        self._title = ""

    def notify(self, event):
        if event.name() == "player_energy_changed":
            self._energy = str(event.energy().value())
        
        # Removed code
        
        elif event.name() == "action_not_completed":
            self._description = "Action was not finished because {}".format(event.reason())

Esto está mejor, no perfecto, pero mucho mejor.

What should I do? >use food
You said: 


--------------------------------------
Action was not finished because You need an object to use it.
Remaining energy: 99
======================================

Por cierto, necesitamos introducir un comando para terminar el juego y no tener que recurrir a Ctrl-C. Otra cosa en la que habrá que pensar más adelante.

Finalmente, otro tema. ¿Qué pasa si un objeto no se puede usar? También debería fallar de manera gracefully, o sea, de buenas maneras, sin romper nada. Esto ocurre cuando el objeto sobre el que se usa Thing no cumple con la interfaz esperada.

Sin embargo, ahora mismo creo es un poco prematuro preocuparse de este asunto. En primer lugar, no tenemos nuevas Thing que se puedan usar de otra forma. Además, cuando usemos otros objetos puede que no los apliquemos a Player, sino que lo intentemos aplicar sobre otras entidades del juego. Quiero decir, que tenemos que saber que Thing no se ha podido usar en ningún otro posible objeto antes de anunciar que no ha tenido efectos. Y podríamos tener situaciones en las que incluso así el objeto no pudiera reutilizarse. Hay muchas cosas que no sabemos todavía.

Próximos pasos

Como he mencionado en esta entrega, quedan un montón de temas abiertos y necesito tomar un tiempo para arreglar cosas antes de que sea demasiado complicado abordarlas al añadir prestaciones nuevas al juego. Por eso, espero que la próxima entrega sea una nueva sesión de revisión y limpieza.

Mis principales objetivos serán acabar de una vez por todas con ActionResult y arreglar las limitaciones de Printer, de modo que la información para la jugadora sea más correcta y completa.

De momento, hemos llegado a este punto en el código como puedes ver en el commit.

Y seguimos en una nueva entrega

December 8, 2022

Etiquetas: python   dungeon   good-practices  

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