Mastodon

Dungeon 12. Otra revisión de código

por Fran Iglesias

El coste de cost

Lo primero que voy a abordar es el tema de Command.cost(). Ahora que he introducido el concepto en UseCommand me gustaría generalizarlo y aplicarlo en todos, eliminando así uno de los últimos usos de ActionResult.

En la clase base Command ya se establece el coste por defecto de cada uno, por lo que solo tendría que sobreescribirlo en aquellos que tengan un coste especial, como GoCommand.

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

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

    def cost(self):
        return EnergyUnit(5)

    def name(self):
        return "go"

Lo siguiente, sería asegurarnos de que calcularemos el coste de la última acción de Player usando este método y no ActionResult.

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

Hay que hacer un cambio en un comando para test:

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

    def do(self, receiver):
        result = ActionResult.player_acted("You did something")
        result.set('cost', self.cost())
        result.set('command', "test command")
        return result

    def cost(self):
        return self._energy_consumption

Ahora veo si puedo quitar sin problema las líneas de Command que rellenan la clave cost de ActionResult.

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

    def do(self, receiver):
        return receiver.go(self._argument)

    def cost(self):
        return EnergyUnit(5)

    def name(self):
        return "go"

Así es. He puesto el ejemplo de GoCommand, pero todos los demás igual.

Por otra parte, tenemos este test de ActionResult, que no me parece que esté aportando gran cosa. En realidad intenta verificar que los distintos constructores de ActionResult funcionan como deben, pero nos los vamos a cargar, así que a la basura con el test.

class TestActionResult(TestCase):
    def test_generic_action_result(self):
        result = ActionResult.player_acted("message")

        self.assertFalse(result.get("exited"))
        self.assertIsNone(result._bag.get("destination"))

    def test_moving_action_result(self):
        result = ActionResult.player_moved("message", 'room')

        self.assertFalse(result.get("exited"))
        self.assertEqual('room', result._bag.get("destination"))

    def test_exit_action_result(self):
        result = ActionResult.player_exited("message")

        self.assertTrue(result.get("exited"))
        self.assertIsNone(result._bag.get("destination"))

    def test_game_started_result(self):
        result = ActionResult.game_started()

        self.assertFalse(result._bag.get("exited"))
        self.assertIsNone(result._bag.get("destination"))

    def test_action_result_with_cost(self):
        result = ActionResult.player_acted("Action")
        result.set('cost', EnergyUnit(3))
        self.assertEqual(EnergyUnit(3), result._bag.get("cost"))

Al quitar el test, podemos ver qué código podemos eliminar de ActionResult porque ya ha dejado de usarse. No conseguimos mucho con eso, pero todavía tenemos varios usos.

En cualquier caso, ahora mismo el único uso de ActionResult tiene que ver con transmitir los “cambios en la escena”, cosa que se hace en Player, que emite un evento cuando ha terminado la acción, como se puede ver en la última línea del bloque. Más sobre eso dentro de un momento.

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

Además, al ver este código nos damos cuenta de por qué el feedback sobre el comando UseCommand es equivocado. Simplemente no se notifica. Hay que cambiar un poquito ese código:

    def _execute_command(self, command, receiver):
        self._last_result = None
        if command.name() == "use":
            command.do(self)
            self._last_command = command
            self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
            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')))

Todavía queda un poco sucio, pero con eso podemos guardar los cambios realizados.

Una cuestión que puedes considerar es por qué usar un método de Command para comunicar a Player el coste de cada comando en lugar de un evento. El motivo es que Command y Player tienen una relación que permite eso. Command se pasa a Player, que puedo interrogarlo para obtener esa información. Podríamos hacerlo de otra manera, para evitar exponer el coste. Pero quizá más adelante.

Cambiando ActionResult por eventos

Mi siguiente objetivo es cargarme ActionResult, que no va a ser cosa sencilla. Para eso tengo empezar a asegurar que todas las acciones se comunican de algún modo.

Mi primer punto de atención es Player. El objetivo es eliminar la emisión del evento que informa del resultado de las acciones que ahora usa el contenido de ActionResult. Tengo que identificar si alguna acción no genera el feedback adecuado para la jugadora.

Otra razón para empezar por aquí es que Player es el último receptor de ActionResult. Si dejo de usarlo aquí, puedo dejar de devolverlo en los Command y así progresivamente hasta suprimirlo del todo y sustituirlo por un sistema mejor.

El problema en este tema es que ando un poco a ciegas. No tengo garantía de tener tests suficientes para el refactor y no sé muy bien como hacerlos.

En este caso podríamos usar, por ejemplo, una técnica Mikado, de modo que las cosas rompan por algún lado y pueda actuar en consecuencia. Por ejemplo, añadiendo tests que reproduzcan esos problemas.

Así que comento esta línea:

    def _execute_command(self, command, receiver):
        self._last_result = None
        if command.name() == "use":
            command.do(self)
            self._last_command = command
            self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
            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')))

Y obtengo un test que falla… test que especificamente verifica ese comportamiento. Así que tengo que eliminar este test o modificarlo.

    def test_notifies_player_got_description_event(self):
        fake_observer = FakeObserver()

        player = Player.awake_with_energy(EnergyUnit(100))
        player.register(fake_observer)

        player.do(TestCommand(EnergyUnit(50)))

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

A favor de eliminarlo está que específicamente espera que Player lance este evento, cosa que no quiero que ocurra. La cuestión es que este evento me interesa como resultado de los comandos Look, por lo que debería testearlo de alguna otra forma.

De momento me inclino por quitarlo, pero tengo que tener esto en cuenta.

Al quitarlo, el resto de tests siguen pasando. Ahora comprobaré manualmente qué problemas puedo tener. En principio esto no afectará a que el juego pueda jugarse, pero las pantallas de juego podrían no tener toda la información necesaria.

La primera en la frente:

What should I do? >look    
You said: look around


--------------------------------------

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

Es exactamente lo que había pensado. El comando Look ahora no comunica lo que se ve. El caso es que casi todos los objetos usan Look. Se me ocurre que Dungeon podría encargarse de lanzar el evento, ya que implementa look y recibe la información de sus rooms.

Esto restaura la funcionalidad de Look en el juego. Y nada más parece afectado. De hecho, parece estar funcionado bien.

    def look(self, focus):
        result = self._current_room().look(focus)
        description = result.get("message")
        self._notify_observers(PlayerGotDescription(description))
        return result

Lo siguiente será revisar que Player no necesita usar más el resultado de los comandos. De hecho, parece que no se usa para nada más, así que voy a quitarlo y ver qué rompe.

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

No falla ningún test y el juego sigue funcionando. Puedo quitar el return del resultado de los Command, pero hay un caso que produce un resultado inesperado. Si introduzco un comando inválido, el sistema no lo detecta. Se puede jugar, pero no nos informa bien:

What should I do? >gter as
You said: None gter as


--------------------------------------

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

Este es el próximo desafío, una vez que limpie el resto de comandos. Es decir, tengo que hacer que cuando el comando no es válido se vea un feedback correcto en la pantalla.

Antes de eso, voy a avanzar un poco con ActionResult. Ahora que los Command no lo devuelven, tampoco es necesario que Dungeon lo haga. Si quito este return lo que me encuentro es que fallan algunos tests porque dependen de esa respuesta. Así que tenemos que cambiarlos primero. Hay que plantearlos para observar otros efectos. Por ejemplo, que se tiran los eventos adecuados.

Por ejemplo, en Dungeon, quitar el return hace que fallen dos tests:

    def look(self, focus):
        result = self._current_room().look(focus)
        description = result.get("message")
        self._notify_observers(PlayerGotDescription(description))

Uno es este:

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

Que debería poder reemplazarse por un test que compruebe el evento:

    def test_can_put_things_in_rooms(self):
        fake_observer = FakeObserver()

        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.register(fake_observer)

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

        last_event = fake_observer.last("player_got_description")
        self.assertIn("Sword", last_event.description())

El otro test que falla es este:

class PlayerGettingThingsTestCase(unittest.TestCase):
    def test_player_get_object_removes_from_room(self):
        player = Player.awake()
        dungeon = self.dungeon_with_object(Thing("Food"))
        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"))

    # Removed code

Y debería poder cambiarse por este:

class PlayerGettingThingsTestCase(unittest.TestCase):
    def test_player_get_object_removes_from_room(self):
        fake_observer = FakeObserver()
        player = Player.awake()
        dungeon = self.dungeon_with_object(Thing("Food"))
        dungeon.register(fake_observer)
        player.awake_in(dungeon)
        get_command = GetCommand("food")
        player.do(get_command)
        dungeon.look('objects')
        last_event = fake_observer.last("player_got_description")
        self.assertIn("There are no objects", last_event.description())

    # Removed code

Ahora vamos con el comando Dungeon.go(). En este caso fallan más tests.

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

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

Por ejemplo, este:

class TestDungeonBuilder(TestCase):
    def test_can_add_room_with_exit_to_North(self):
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())

        dungeon = builder.build()
        result = dungeon.go('north')
        self.assertTrue(result.get("exited"))

Estamos en la misma situación. Este test puede cambiarse para chequear el evento:

class TestDungeonBuilder(TestCase):
    def test_can_add_room_with_exit_to_North(self):
        fake_observer = FakeObserver()
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())

        dungeon = builder.build()
        dungeon.register(fake_observer)

        dungeon.go('north')
        self.assertTrue(fake_observer.is_aware_of("player_exited"))

El segundo test que falla podría cambiarse también:

    def test_can_add_room_with_several_doors(self):
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())
        builder.set('start', Dir.S, Exit())

        dungeon = builder.build()

        result = dungeon.go('north')
        self.assertTrue(result.get("exited"))
        result = dungeon.go('south')
        self.assertTrue(result.get("exited"))

Aunque es un poquito extraño:

    def test_can_add_room_with_several_doors(self):
        fake_observer = FakeObserver()
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())
        builder.set('start', Dir.S, Exit())

        dungeon = builder.build()
        dungeon.register(fake_observer)

        dungeon.go('north')
        self.assertTrue(fake_observer.is_aware_of("player_exited"))

        dungeon.go('south')
        self.assertTrue(fake_observer.is_aware_of("player_exited"))

El último test en fallar necesita el mismo tratamiento:

    def test_can_add_several_rooms(self):
        builder = DungeonBuilder()
        builder.add('101')
        builder.add('start')
        builder.set('101', Dir.S, Exit())
        builder.set('start', Dir.N, Exit())

        dungeon = builder.build()

        result = dungeon.go('north')
        self.assertTrue(result.get("exited"))

Usando las notificaciones igualmente.

    def test_can_add_several_rooms(self):
        fake_observer = FakeObserver()
        builder = DungeonBuilder()
        builder.add('101')
        builder.add('start')
        builder.set('101', Dir.S, Exit())
        builder.set('start', Dir.N, Exit())

        dungeon = builder.build()
        dungeon.register(fake_observer)

        dungeon.go('north')
        self.assertTrue(fake_observer.is_aware_of("player_exited"))

Para terminar esta acción de limpieza quedarían algunos pasos más. Es necesario probarlo manualmente, para asegurar que la jugadora no pierde información. Esta prueba no muestra ningún problema aparente, por lo que seguimos adelante.

Lo siguiente que necesito revisar es seguir eliminando retornos innecesarios.

Dungeon.go() ya no devuelve nada ni usa el retorno que viene de sus miembros, así que vamos a empezar por ahí.

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

    # Removed code

Y observamos que falla un test. Este test igualmente debería basarse en eventos. El problema es que no tenemos eventos para todo:

class TestRoom(TestCase):
    
    # Removed code

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

Así que tendremos que introducir nuevos:

    def test_wall_in_all_directions(self):
        self.room.go(Dir.N)
        self.assertTrue(self.fake_observer.is_aware_of("player_exited"))
        
        self.room.go(Dir.E)
        self.assertTrue(self.fake_observer.is_aware_of("player_hit_wall"))
        
        self.room.go(Dir.S)
        self.assertTrue(self.fake_observer.is_aware_of("player_hit_wall"))
        
        self.room.go(Dir.W)
        self.assertTrue(self.fake_observer.is_aware_of("player_hit_wall"))

El emisor de este evento será Wall, al igual que Door y Exit emiten los suyos.

class Wall:
    def __init__(self):
        self._subject = Subject()

    def go(self):
        self._notify_observers(PlayerHitWall())
        return ActionResult.player_acted("You hit a wall")

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

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

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

Y este es el evento:

class PlayerHitWall:
    def __init__(self):
        pass

    def name(self):
        return "player_hit_wall"

Evento que tenemos que esuchar en 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())
        
        # Removed code
            
        elif event.name() == "player_hit_wall":
            self._description = "You hit a wall. There is no door."

A continuación, sigo eliminando retornos del método go en Wall, Door y Exit. Me falla este test:

class TestWalls(TestCase):
    def setUp(self):
        self.walls = Walls()
    
    # Removed code

    def test_door_go_moves_player_to_another_room(self):
        door = Door('destination')
        result = door.go()

        self.assertEqual('destination', result._bag.get("destination"))

De nuevo es un test que debería comprobar que se lanza el evento adecuado:

    def test_door_go_moves_player_to_another_room(self):
        fake_observer = FakeObserver()
        door = Door('destination')
        door.register(fake_observer)

        door.go()

        event = fake_observer.last("player_moved")
        self.assertEqual('destination', event.room())

Con todos estos arreglos, los tests pasan y ya no queda ningún método go devolviendo nada, si no que sus efectos se publican como eventos.

Y eso me permite quitar más cosas de ActionResult que ahora contiene solo la clave “message”.

class ActionResult:
    @classmethod
    def player_acted(cls, message):
        return cls(message, None, False)

    def __init__(self, message, destination=None, exited=False):
        self._bag = ResultBag()
        self._bag.set("message", message)

    def get(self, key):
        return self._bag.get(key)

    def set(self, key, data):
        self._bag.set(key, data)

Ahora mismo todas las descripciones le van “cayendo” a Dungeon, que las envía como evento. Quiero evitar eso, de modo que cada objeto se encargue de notificar la descripción que le corresponda. Así, Dungeon no tendría una descripción propia, pero Room sí, pues responde a look around y look objects. Si fuera necesario, aunque no está implementado todavía, un comando como look food o look sword apuntaría los objetos Thing correspondientes.

Resumiento, en Dungeon, quiero pasar de esto:

    def look(self, focus):
        result = self._current_room().look(focus)
        description = result.get("message")
        self._notify_observers(PlayerGotDescription(description))

A esto:

    def look(self, focus):
        self._current_room().look(focus)

Por supuesto, fallan algunos tests hago este cambio porque ya no se lanza el evento necesario. Por ejemplo:

class PlayerGettingThingsTestCase(unittest.TestCase):
    def test_player_get_object_removes_from_room(self):
        fake_observer = FakeObserver()
        player = Player.awake()
        dungeon = self.dungeon_with_object(Thing("Food"))
        dungeon.register(fake_observer)
        player.awake_in(dungeon)
        get_command = GetCommand("food")
        player.do(get_command)
        dungeon.look('objects')
        last_event = fake_observer.last("player_got_description")
        self.assertIn("There are no objects", last_event.description())

Room sería la responsable de lanzar este evento. Y estos son los cambios que necesita, los cuales rompen varios tests, aunque son fáciles de arreglar.

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

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

    def _look_objects(self):
        response = self._things.look()
        self._notify_observers(PlayerGotDescription(response))

    def _look_around(self):
        response = self._things.look()
        response += self._walls.look()
        self._notify_observers(PlayerGotDescription(response))

Los siguientes pasos que necesito resultan un poco más complicados, ya que quiero quitar ActionResult de sus últimas usuarias: la familia de objetos Wall.

Para facilitar el trabajo, creo que primero debería limpiar un poco el código ya que forman una jerarquía en la que pueden compartir funcionalidad a través de su clase base Wall. Aquí tenemos el ejemplo de Wall y Exit:

class Wall:
    def __init__(self):
        self._subject = Subject()

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

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

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

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


class Exit(Wall):
    def __init__(self):
        super().__init__()

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

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

Para empezar, voy a eliminar la necesidad de ActionResult añadiendo un método alternativo. Aquí el ejemplo:

class Exit(Wall):
    def __init__(self):
        super().__init__()

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

    def look(self):
        return ActionResult.player_acted(self.description())

    def description(self):
        return "There is a door"

Este método es el que usaré en Walls, para construir la respuesta que se devuelve en look.

    def look(self):
        response = ""
        for dirs in Dir:
            response += "{0}: {1}\n".format(str(dirs.value).capitalize(), self._walls[dirs].description())

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

Ahora debería poder reemplazar el cuerpo de los métodos look por la publicación de un evento. Además, me basta con tenerlo en la clase base, ya que ahora sería un template method que llama a self.description()

class Wall:
    def __init__(self):
        self._subject = Subject()

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

    def look(self):
        self._notify_observers(PlayerGotDescription(self.description()))

    def description(self):
        return "There is a wall"

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

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

Y, como resultado, ya no tengo más usos de ActionResult. Así que puedo eliminarlo por fin.

Quitando más cosas feas

¿Qué es código feo? El objetivo del diseño de software no es hacer código bonito, sino código bien organizado, fácil de comprender, de mantener y de extender. Eventualmente, el código bien diseñado suele mostrar cierta estética. Es difícil de definir pues se trata de una sensación.

Y del mismo modo, se podría percibir cierta fealdad en el código mal diseñado. Es un concepto similar al de smell: algo que puede funcionar correctamente, pero que podría indica que algo no está bien hecho.

Fealdad y falta de cohesión

En el código de Dungeon podemos apreciar algunas áreas de código que se aprecian como feas. Vamos a ver qué nos dicen. Por ejemplo:

class Application:
    
    # Removed code
    
    def _setup_player(self, dungeon):
        player = Player.awake()
        player.register(self._printer)
        dungeon.register(self._printer)
        player.awake_in(dungeon)
        return player

Claramente, la línea dungeon.register(self._printer) parece fuera de lugar. Lo vemos por dos razones:

  • El método se refiere claramente a Player, no a Dungeon.
  • La llamada a dungeon.register introduce una pequeña distorsión visual. Como un granito o una motita en la uniformidad de usos de player.

En este caso la fealdad nos está señalando que hay problemas de cohesión. No todas las líneas del método están contribuyendo a lo mismo.

La solución es bien sencilla: hay que quitar esa línea y llevarla a otro lado. Ahora estos métodos se ven menos feos Al menos, son más cohesivos.

class Application:
    
    # Removed code

    def _setup_player(self, dungeon):
        player = Player.awake()
        player.register(self._printer)
        player.awake_in(dungeon)
        return player

    def _build_dungeon(self, dungeon_name):
        dungeon = self._factory.make(dungeon_name)
        dungeon.register(self._printer)
        return dungeon

Lios

¿Qué me llama la atención para mal de este fragmento?

    def run(self, dungeon_name='game'):
        self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
        dungeon = self._build_dungeon(dungeon_name)
        player = self._setup_player(dungeon)
        game = Game(player=player, obtain_input=self._obtain_user_command, printer=self._printer)
        dungeon.register(game)
        game.run()

Al menos dos cosas:

  • La primera línea, que se siente fuera de lugar porque estamos imprimiendo sin usar Printer, con un objeto del que solo poblamos una de cuatro propiedades. Además, es una línea muy larga. De nuevo, su aspecto nos hace arrugar el ceño al verla.
  • El hecho de que tengamos que terminar preparar dungeon después de instanciar Game, por culpa de que antes necesitamos tener a Player. Esto me hace dudar de si es buena idea montar Game con Player. Posiblemente, tenga más sentido pasar Player al método run. De nuevo, es una línea larga, que se ve fuera de lugar y que, además, provoca que el orden de las líneas no sea adecuado para leer.

Sobre el primer problema volveré luego ya que implica algunos cambios más profundos.

El segundo problema debería ser más fácil de manejar. En primer lugar, pasamos player al método run de Game.

class Game:
    # Removed code

    def run(self, player):
        while not self.finished():
            self._player.do(self._input.command())
            self._printer.draw()
    
    # Removed code

A continuación, empezamos a usarlo en ese método.

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

Una vez hemos reemplazado los usos de self._player por player, podemos eliminarlo:

class Game:
    def __init__(self, player, obtain_input, printer):
        self._finished = False
        self._input = obtain_input
        self._printer = printer

Y ya no lo tenemos que pasar en la construcción.

class Game:
    def __init__(self, obtain_input, printer):
        self._finished = False
        self._input = obtain_input
        self._printer = printer
class Application:

    # Removed code

    def run(self, dungeon_name='game'):
        self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
        dungeon = self._build_dungeon(dungeon_name)
        player = self._setup_player(dungeon)
        game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
        dungeon.register(game)
        game.run(player)

Lo que nos permite reorganizar el código de forma más legible, llevando toda la preparación de Dungeon a su método:

class Application:
    
    # Removed code
    
    def run(self, dungeon_name='game'):
        self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
        game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
        dungeon = self._build_dungeon(dungeon_name, game)
        player = self._setup_player(dungeon)
        game.run(player)

    def _setup_player(self, dungeon):
        player = Player.awake()
        player.register(self._printer)
        player.awake_in(dungeon)
        return player

    def _build_dungeon(self, dungeon_name, game):
        dungeon = self._factory.make(dungeon_name)
        dungeon.register(self._printer)
        dungeon.register(game)
        return dungeon

Todos estos pasos los hemos dado sin que los tests se resintiesen en ningún momento.

Bonita impresión

Para atacar el problema de la primera línea se me ocurre que sigo viendo algunos aspectos molestos en el modo en que se montan los objetos del juego y el momento en que se montan.

    def run(self, dungeon_name='game'):
        self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
        game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
        dungeon = self._build_dungeon(dungeon_name, game)
        player = self._setup_player(dungeon)
        game.run(player)

Si volvemos a la narrativa del juego, la jugadora se “despierta” en la mazmorra. Es cuando el juego empieza, pero nosotros hacemos eso en la preparación:

    def _setup_player(self, dungeon):
        player = Player.awake()
        player.register(self._printer)
        player.awake_in(dungeon)
        return player

    def _build_dungeon(self, dungeon_name, game):
        dungeon = self._factory.make(dungeon_name)
        dungeon.register(self._printer)
        dungeon.register(game)
        return dungeon

Deberíamos moverlo a Game.run. La preparación en este nivel estaría reservada a las cuestiones más técnicas.

Eso implica que tendríamos que pasar dungeon a Game.run, para poder hacer el player.awake_in.

Básicamente, esto:

class Application:
    
    # Removed code
    
    def run(self, dungeon_name='game'):
        self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
        game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
        player = self._setup_player()
        dungeon = self._setup_dungeon(dungeon_name)
        game.run(player, dungeon)

    def _setup_player(self):
        player = Player.awake()
        player.register(self._printer)
        return player

    def _setup_dungeon(self, dungeon_name):
        dungeon = self._factory.make(dungeon_name)
        dungeon.register(self._printer)
        return dungeon

Y esto:

class Game:
    
    # Removed code

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

    # Removed code

Ahora es cuando tratamos el problema de la pantalla de bienvenida. Si me fijo en el código de Game.run podría tener sentido invocar el dibujo de esa pantalla antes del bucle.

Esa pantalla reflejaría los eventos previos al inicio del bucle… Específicamente, que la jugadora despierta dentro de la mazmorra. Así que el método awake_in es el lugar adecuado para tirar el evento PlayerAwake.

    def test_notifies_player_awake(self):
        fake_observer = FakeObserver()

        player = Player.awake_with_energy(EnergyUnit(100))
        player.register(fake_observer)

        player.awake_in(Dungeon(Rooms()))

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

El evento:

class PlayerAwake:
    def __init__(self):
        pass

    def name(self):
        return "player_awake"

Y lo manejamos en 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())
        
        # Removed code
            
        elif event.name() == "player_awake":
            self._title = "Welcome to the Dungeon"
            self._energy = "100"

Y esta solución mantiene los tests pasando y muestra una pantalla de bienvenida, aunque necesita un par de arreglos:

You said: 

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

Remaining energy: 100
======================================

Necesitamos introducir un poco de código que controle si algunos campos no necesitan imprimirse:

class RichConsoleShowOutput(ShowOutput):
    def put(self, scene):
        if scene.command() != "":
            print("You said: {}\n".format(scene.command()))
        print("{}".format(scene.title()))
        print("--------------------------------------")
        print("{}".format(scene.description()))
        print("Remaining energy: {}".format(scene.energy()))
        print("======================================")

Pero uno de los mejores efectos de este cambio es el aspecto que tiene ahora Application. Aún tenemos cosas que arreglar en la inicialización, pero ahora tenemos métodos cortos, cohesivos y sencillos.

class Application:
    def __init__(self, obtain_user_command, show_output, factory, toggles):
        self._toggles = toggles
        self._obtain_user_command = obtain_user_command
        self._printer = Printer(show_output)
        self._factory = factory

    def run(self, dungeon_name='game'):
        player = self._setup_player()
        dungeon = self._setup_dungeon(dungeon_name)
        game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
        game.run(player, dungeon)

    def _setup_player(self):
        player = Player.awake()
        player.register(self._printer)
        return player

    def _setup_dungeon(self, dungeon_name):
        dungeon = self._factory.make(dungeon_name)
        dungeon.register(self._printer)
        return dungeon

Aquí puedes ver el estado del código en este momento.

Como nota al margen, señalaría que posiblemente algunos eventos se puedan enriquecer con más información. Por ejemplo, este último PlayerAwake, podría llevar información como el nombre de la mazmorra, así como la energía con la que comienza la jugadora.

El nacimiento de Player

Esto me lleva a un último tema en esta entrega. Al principio quise ser fiel a la narrativa del juego creando un constructor awake para Player, pero eso es algo que no ha evolucionado correctamente.

Player nace antes de despertarse en la mazmorra y es en ese momento cuando se le asignan algunas propiedades iniciales. La más importante, por el momento, es el nivel de energía que normalmente será 100, pero que en algunos tests se manipula por conveniencia.

Así que simplemente he decidido eliminar estos constructores y dejar únicamente el constructor nativo:

class Player:
    def __init__(self, starting_energy=EnergyUnit(100)):
        self._energy = Energy(starting_energy)
        self._subject = Subject()
        self._receiver = None
        self._holds = None
        self._last_command = None

Próximos pasos

En este momento tengo en la cabeza varias ideas que me gustaría explorar.

  • Meta programación: hay algunas secciones del programa que podrían beneficiarse de técnicas de meta programación, para registrar automáticamente comandos o diseños de mazmorras.
  • Configuración: el diseño de las mazmorras podría beneficiarse de un sistema de configuración en lugar de construir con código. Esto nos permite separar ambos aspectos, facilitando también .
  • *Limpieza de tests: muchos tests se basan en capturar eventos, por lo que podría tener sentido unificar el tooling del test para facilitarlo. Lo mismo para tests que buscan verificar la interacción o la salida por la consola.

En lo que toca a prestaciones del juego:

  • Introducir nuevos objetos con nuevas posibilidades de interacción: puertas que se abren con llaves que debemos recoger en nuestro recorrido, armas, etc., y en algún momento enemigos.
  • Jordi Martínez ha sugerido en artículos anteriores sobre la posibilidad de que la jugadora no tenga una visión completa de la mazmorra, así como que las habitaciones o celdas tengan espacio. Son ideas interesantes que podría explorar.
  • Dar soporte para que las celdas y objetos tengan descripciones largas, que permitan crear historias más interesantes y sugerentes.

Continúa aquí

December 13, 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