Dungeon 9. El camino equivocado

por Fran Iglesias

Antes de pasar a otra iteración quiero dedicar un rato a pensar sobre ActionResult, ya que no me convence el planteamiento que he tenido hasta ahora. Y alguna cosa más.

Mentiría si dijese que esta entrega es la descripción de un spike. Pero no, es una opción de diseño que funciona, pero que ha resultado muy mala. Hay demasiadas cosas que no cierran bien.

Voy a dejar aquí el relato de lo que parecía una buena idea. En parte, porque quizá no sea mala idea dejar constancia de las vías muertas en las que entro.

¿Merece la pena deshacer este trabajo y empezar de nuevo desde el principio? En este caso, creo que no, ya que la evolución del código me ha permitido darme cuenta de otras opciones , además, en algunos casos sí que ha dejado las cosas mejor de lo que estaban.

El camino sin salida

ActionResult vendría siendo un DTO que nos sirve para mover información no relacionada de un lado a otro. En Dungeon, ActionResult se inicia en un objeto, pero puede ser enriquecido con más cosas. Esta es su interfaz:

class Result:
    def message(self):
        pass

    def is_finished(self):
        pass

    def moved_to(self):
        pass

    def cost(self):
        pass

Normalmente, ActionResult comenzará siempre con un mensaje. En algunos objetos, como Door, puede recibir información extra, como la celda a la que se va a mover la jugadora. En el caso de Exit, se anota también que la jugadora ha conseguido terminar el juego.

Una información extra que se añade es el coste en energía de cada acción. Esta información la añade el Command.

Por otro lado, cada pieza de información parece interesarle a distintos consumidores.

Esto hace que no me emocione mucho el diseño actual. Si en el futuro necesito añadir alguna información más, cosa que es posible, tendría que modificar esta interfaz. Por ejemplo. Imagina que como resultado de una acción la jugadora sufriera un daño, además del gasto de energía. Habría que añadir un método nuevo.

En realidad, al pensar en ActionResult no pienso en su comportamiento, sino en la información que contiene. En ese sentido, lo que me interesa es poder guardar y reclamar información, identificada por algún nombre. Por esa razón, creo que sería preferible adoptar otro patrón.

Por tanto, ActionResult, o Result, podría ser un diccionario que contiene una descripción del estado del juego tras cada acción.

Luego, una opción que se me ocurre, es que haya objetos especializados en introducir y extraer determinada información en ActionResult, dependiendo de su consumidor. Es algo parecido a los decoradores que proponíamos en una entrega anterior.

Por eso, sería interesante ver cómo se consume. Player es el mayor consumidor, aunque parte de la información interesa a otros consumidores.

class Player:
    def __init__(self, starting_energy):
        self._last_message = "I'm ready"
        self._energy = Energy(starting_energy)
        self._exited = False

    @classmethod
    def awake(cls):
        return cls(EnergyUnit(100))

    @classmethod
    def awake_with_energy(cls, starting_energy):
        return cls(starting_energy)

    def do(self, command, receiver):
        result = command.do(receiver)
        self._energy.decrease(result.cost())
        self._exited = result.is_finished()
        self._last_message = result.message()

    def is_alive(self):
        return self._energy.is_alive()

    def has_won(self):
        return self._exited

    def said(self):
        return "{message}\nRemaining energy: {energy}".format(message=self._last_message, energy=self._energy)

Claramente, cost es un dato que interesa a Player.

Result.is_finished lo recogemos en Player, pero lo evaluamos en Application. Por otro lado, claramente tenemos un problema de consistencia con el naming en este aspecto.

Por la parte de message, también tenemos algo interesante. Lo estamos recogiendo en Player, y modificando, pero realmente no estamos interesadas en él, sino que lo pasamos a Application, que se lo pasa a ShowOutput.

Para mí, esto apunta a que Player podría recoger el objeto Result para que lo consuma Application y lo pase a ShowOutput. O bien que Result sirva como fuente de datos para obtener una descripción de la situación actual del juego que sirva para construir la respuesta mostrada en la consola.

Son bastantes cambios, así que, voy a intentar ir paso a paso a partir de lo que tenemos.

Una bolsa de datos

Para empezar, creo que voy a crear una neva clase ResultBag que pueda contener la información que me interesa. Será un diccionario, pero lo encapsularé.

class ResultBag():
    def __init__(self):
        self._results = dict()

    def set(self, key, data):
        self._results[key] = data

    def get(self, key):
        return self._results[key]

Esto puede vivir dentro del actual ActionResult.

class ActionResult(Result):
    
    # Removed code
    
    def __init__(self, message, destination=None, exited=False):
        self._message = message
        self._destination = destination
        self._exited = exited
        self._bag = ResultBag()

    # Removed code

Y podríamos empezar a usarlo:

class ActionResult(Result):

    # Removed code

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

    def message(self):
        return self._bag.get("message")

    def is_finished(self):
        return self._bag.get("exited")

    def moved_to(self):
        return self._bag.get("destination")

Estos cambios no alteran los tests y podemos consolidarlos.

Lo siguiente sería añadir nuevos métodos de acceso a las propiedades. Se trata de simples wrappers.

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

    def message(self):
        return self._bag.get("message")

    def is_finished(self):
        return self._bag.get("exited")

    def moved_to(self):
        return self._bag.get("destination")
    
    def get(self, key):
        return self._bag.get(key)
    
    def set(self, key, data):
        self._bag.set(key, data)

Nos falta tratar cost, que tiene una situación un tanto especial, ya que se introduce con WithCost que decora a ActionResult. De paso, podríamos arreglar un poco el lío de herencia y decoradores, que ahora ya no nos va a hacer falta.

Para obviar la necesidad de WithCost debería bastar con añadir un método en ActionResult durante el tiempo que necesito para hacer el refactor. Esto lo hago así porque en Player estoy llamando al método cost() del objeto resultado, que es poblado por los Command.

    def cost(self):
        return self._bag.get("cost")

Gracias a este cambio, debería poder cambiar estas líneas en los Command

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

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

    def _name(self):
        return "go"

Por estas:

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

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

    def _name(self):
        return "go"

Lo que elimina la necesidad de usar WithCost y, de hecho, la necesidad de que ActionResult herede de Result. A continuación elimino todos los usos de WithCost y suprimo las clases WithCost y Result, que ya no necesito. Consolido estos cambios.

Luego, hay que eliminar los métodos antiguos, de modo que los consumidores de ActionResult no dependan de ellos, sino de los nuevos get y set. Esto hará que ActionResult quede así:

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

    @classmethod
    def player_moved(cls, message, destination):
        return cls(message, destination, False)

    @classmethod
    def player_exited(cls, message):
        return cls(message, None, True)

    @classmethod
    def game_started(cls):
        return cls("")

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

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

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

Ahora tenemos una interfaz más flexible para ActionResult, pero usar keys es mucho más frágil. Ya veremos como abordar eso. Todavía hay más cosas que me gustaría afinar.

Para eso tenemos que irnos a sus consumidores. Como Player.

La idea básica es que en lugar de guardar diversas propiedades, Player pueda quedarse con el último ActionResult y usar nada más que lo necesario, así como pasarlo a Application tal cual.

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

    @classmethod
    def awake(cls):
        return cls(EnergyUnit(100))

    @classmethod
    def awake_with_energy(cls, starting_energy):
        return cls(starting_energy)

    def do(self, command, receiver):
        self._last_result = command.do(receiver)
        self._energy.decrease(self._last_action_cost())

    def _last_action_cost(self):
        return self._last_result.get("cost")

    def last_result(self):
        return self._last_result

    def is_alive(self):
        return self._energy.is_alive()

    def has_won(self):
        return self._last_result.get("exited")

    def said(self):
        return "{message}\nRemaining energy: {energy}".format(message=self._message(), energy=self._energy)

    def _message(self):
        return self._last_result.get("message")

Con esto pretendo eliminar algunos métodos de Player, particularmente has_won y said, que exponen comportamientos que no corresponden realmente a la jugadora.

Lo siguiente es empezar a usar result en Application.

    def run_with_player(self, dungeon_name):
        self._show_message("Welcome to the Dungeon")
        dungeon = self._build_dungeon(dungeon_name)
        player = Player.awake()
        
        while player.is_alive() and not player.has_won():
            command = self._obtain_command()
            player.do(command, dungeon)
            result = player.last_result()
            
            self._show_message(str(command))
            self._show_message(player.said())

Tengo varias cosas que me molestan aquí. De momento me voy a centrar en la actualización de la consola. Hay dos llamadas a self._show_message, nombre que resulta bastante poco adecuado ahora. Además, Player todavía construye parte de lo que debería mostrarse. O sea, se está encargando de detalles de implementación.

Básicamente, lo que yo querría es pasar una descripción de la escena basada en el resultado. Los diferentes elementos del juego aportarían información a ActionResult y con esa información, alimentaríamos una estructura capaz de describir una escena. ¿Cómo podría ser ese objeto? A mí se me ocurre que debería describir, al menos:

  • Un título: podría ser el nombre de la celda
  • La última orden de la jugadora: el famoso “eco”
  • El resultado de la última acción: el mensaje con el que inicializamos ActionResult
  • El nivel de energía restante

Este objeto podría leer de ActionResult lo que necesite.

Lo primero que voy a hacer es asegurarme de que todos los objetos que me interesan, rellenan la información necesaria en ActionResult. Aquí Player:

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

    @classmethod
    def awake(cls):
        return cls(EnergyUnit(100))

    @classmethod
    def awake_with_energy(cls, starting_energy):
        return cls(starting_energy)

    def do(self, command, receiver):
        self._last_result = command.do(receiver)
        self._energy.decrease(self._last_action_cost())
        self._last_result.set("energy", str(self._energy))

    def _last_action_cost(self):
        return self._last_result.get("cost")

    def last_result(self):
        return self._last_result

    def is_alive(self):
        return self._energy.is_alive()

    def has_won(self):
        return self._last_result.get("exited")

    def said(self):
        return "{message}\nRemaining energy: {energy}".format(message=self._message(), energy=self._energy)

    def _message(self):
        return self._last_result.get("message")

Y aquí, GoCommand, y todos los demás Command:

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

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

    def _name(self):
        return "go"

Esto quizá pueda resolverse aún mejor con un decorador de Python, pero lo dejamos para otro momento. Lo interesante es que ahora ActionResult lleva la información que necesitamos para describir una escena.

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

    @classmethod
    def from_result(cls, result):
        return cls(
            title=result.get("title"),
            command=result.get("command"),
            description=result.get("message"),
            energy=result.get("energy")
        )

    def title(self):
        return self._title

    def command(self):
        return self._command

    def description(self):
        return self._description

    def energy(self):
        return self._energy

ShowOutput debería recibir un objeto Scene y representar sus distintas partes con una plantilla. Por ejemplo, algo así:

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

Esto todavía tiene algunas imperfecciones pero nos permite tener cosas así:

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

15
--------------------------------------
You move to room '15'
Remaining energy: 94
======================================

Hago estos cambios en Application:

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

    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 = Player.awake()
        while player.is_alive() and not player.has_won():
            command = self._obtain_command()
            player.do(command, dungeon)
            result = player.last_result()
            self._show_scene(Scene.from_result(result))

    def _obtain_command(self):
        return self._obtain_user_command.command()

    def _show_scene(self, scene):
        self._show_output.put(scene)

    def _build_dungeon(self, dungeon):
        return self._factory.make(dungeon)

Hay otros cambios en el código para ajustarse a esto, tanto añadiendo como borrando. Algunos tests me impiden terminar de quitar métodos de Player, así que es algo que habría que revisar.

Veo cosas aquí que me siguen preocupando. Por ejemplo, pasar la misma instancia de Dungeon a Player en cada ciclo me parece, como mínimo raro.

En fin, el desastre, en todo su esplendor, lo puedes ver en este enlace.

Observando un enfoque nuevo

Por otro lado, mientras hago este refactor empiezo a pensar en la posibilidad de introducir un patrón Observer. ¿Y eso por qué, dado el pequeño tamaño del proyecto? La cuestión es que ActionResult es un objeto que básicamente va recogiendo y pasando información a objetos. La mayoría de las veces, estos solo están interesados en un aspecto muy específico.

Observer es un patrón que nos permite que objetos distantes puedan reaccionar a cambios. Por ejemplo, ShowOutput podría actualizar la escena que va a mostrar en pantalla si se suscribe a los cambios en las distintas partes del juego. Por ejemplo, los campos de Scene:

  • Command necesita saber si la jugadora ha introducido una nueva orden
  • Title necesita saber si se ha movido a una nueva celda
  • Description necesita saber el resultado de una orden
  • Energy necesita saber si ha cambiado la energía de la jugadora

Por otro lado, Dungeon podría estar interesada en saber si la jugadora se ha movido.

En cualquier caso, puede ser interesante ver como implementar el patrón como ejercicio. Vamos a verlo.

El patrón observer

El problema que resuelve el patrón observer es que un objeto, el observer, pueda enterarse de los cambios que se producen en otro, el subject, y reaccionar en consecuencia, sin que tengamos que llamarlo explícitamente. Es un patrón muy típico en las interfaces de usuario, aunque puede tener muchos más usos.

Un aspecto interesante de este patrón es que un mismo subject puede tener varios observers, que hagan cosas diversas ante los cambios del subject.

En el patrón observer, los observers se registran o suscriben en el subject como interesados en ser notificados de ciertos eventos que pueden ocurrir en el subject. Cuando uno de estos eventos ocurre, el subject llama a los observers pasándoles un mensaje de notificación con los detalles.

Es posible que esto te suene a eventos y con razón. Se trata de un patrón similar. En una aplicación grande esto se suele hacer con buses de eventos. Nosotras lo haremos con suscripción directa a los subjects.

Vamos de nuevo a nuestro problema. Lo que nos interesa es que la consola se actualice cuando haya ciertos cambios en nuestro juego. Básicamente, cuando la jugadora hace cosas. Ahora mismo, lo que hacemos es pasar un objeto que va acumulando toda esa información para que llegue al objeto responsable de mostrar lo que ocurre en la consola.

La idea sería que este objeto que dibuja la consola observe los cambios, y los vaya guardando para el momento en que tenga que dibujar la pantalla, al final del ciclo de juego, sin que Player le tenga que pasar información en ese momento.

Para ser subject, Player tiene que implementar dos cosas principalmente:

  • Un método para que los observers puedan suscribirse a ciertos eventos del subject.
  • Métodos para notificar a los observers de que el evento ha ocurrido.

Y cualquier objeto que quiera observar a Player tiene que tener:

  • Un método para que Player le notifique de un evento que ha sucedido.

Para simplificar podríamos hacer algo así, aunque la implementación real será algo más sofisticada:

# The subject
class Player():
    def register(self, observer, event):
        pass

    def notify(self, event):
        self.observers[event].notify(event)

Una alternativa es no asociar el observer con un evento específico. En este caso, el subject notifica a todos los observers de todos los eventos:

# The subject
class Player():
    def register(self, observer):
        pass

    def notify(self, event):
        self.observers.notify(event)

El primer ejemplo funciona mejor cuando hay muchos observers que atienden a diferentes eventos. Es un poco menos costoso ya que se notifica solo a los observers que están interesados en un evento específico, a cambio de tener que prestar más atención a los eventos que tenemos que definir en el subject.

El segundo ejemplo es más sencillo de implementar, aunque algo menos optimizado por enviar todos los eventos a todos los observers, que simplemente tienen que ignorar los eventos que no quieran manejar.

Los observers por su parte tendrían una estructura similar a esta:

# An observer
class Printer()
    def notify(self, event, data):
        if event == 'some-event':
            self.apply_event()

Player como subject

Para empezar a implementar Player como subject empezaré con un test. En este test, podré registrar un observer y verificaré que es notificado del cambio que se haya registrado para escuchar.

Empezaré por un evento bastante importante: player_energy_changed.

Necesito unas cuantas cosas en el test, que he separado del test de Player. De este modo, centramos los tests en comportamientos concretos, no en las clases.

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._energy_consumption)
        result.set('command', "test command")
        return result


class FakeEnergyObserver:
    def __init__(self):
        self._events = dict()

    def notify(self, event):
        self._events[event.name()] = event

    def is_aware_of(self, event_name):
        return event_name in self._events.keys()


class PlayerEnergyChanged():
    def __init__(self, updated_energy):
        self._updated_energy = updated_energy

    def name(self):
        return "player_energy_changed"

class PlayerAsSubjectTestCase(unittest.TestCase):
    def test_can_register_an_observer_and_notify(self):
        fake_observer = FakeEnergyObserver()

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

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

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

TestCommand es un comando cuya única misión es que la jugadora gaste energía. FakeObserver es un observador que va a estar escuchando el evento player_energy_changed. Este evento se modela con la clase PlayerEnergyChanged.

En el test podemos ver como player registra a FakeObserver como observador del evento player_energy_changed y tras ejecutar el comando verificamos que lo ha recibido.

Para hacer pasar el test necesitaremos añadir algo de código en Player. De momento, lo mínimo para que la cosa funcione:

class Player:
    def __init__(self, starting_energy):
        self._energy = Energy(starting_energy)
        self._last_result = ActionResult.player_acted("I'm ready")
        self._observers = []

    # Removed code
    def register(self, observer):
        self._observers.append(observer)

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

Y con esto pasamos el test.

En cuando a la notificación. Lo que hacemos es recorrer la lista de observers y pasarles el mensaje notify con el evento.

Esto se hace cuando el evento sucede:

    def do(self, command, receiver):
        self._last_result = command.do(receiver)
        self._energy.decrease(self._last_action_cost())
        self._notify_observers(PlayerEnergyChanged(self._energy.current()))
        self._last_result.set("energy", str(self._energy))

Con esto ya tenemos soporte para registrar observers y notificarlos con eventos.

Obviamente, necesitaremos al menos un observer para empezar a usar este nuevo diseño. Lo que haré será introducir un nuevo objeto que se haga responsable de dibujar la consola, escuchando los eventos para tener los datos que necesita. No va a dibujarlos “en tiempo real”, algo que no tiene sentido en este juego, pero sí que actualiza sus datos para dibujar la pantalla cuando se le pide con lo que sepa en ese momento.

En realidad, voy a introducir un nuevo puerto para dibujar el estado del juego en lugar del ShowOutput que tenemos ahora. Para simplificar, y mientras no encuentro un nombre mejor, la clase base será Printer y será capaz de atender eventos.

Aunque el uso de observers parezca un poco sobre ingeniería, en realidad facilita mucho las cosas. Por ejemplo, puedes hacer tests mucho más independientes de setups complicados. Por ejemplo, podemos hacer los tests de Printer simplemente pasándole los eventos a los que queremos que reaccione.

class PrinterAsObserverTestCase(unittest.TestCase):
    def test_handles_energy_changed_event(self):
        event = PlayerEnergyChanged(EnergyUnit(50))
        
        printer = Printer()
        printer.notify(event)

        output = printer.draw()        
        self.assertIn("50", output)

La primera implementación es esta, que realmente no hace nada:

class Printer:
    def __init__(self):
        pass

    def notify(self, event):
        pass

    def draw(self):
        return "50"

Añadiendo un test:

    def test_handles_different_energy_changed_event(self):
        event = PlayerEnergyChanged(EnergyUnit(60))

        printer = Printer()
        printer.notify(event)

        output = printer.draw()
        self.assertIn("60", output)

Me puedo forzar a una implementación algo más inteligente:

class Printer:
    def __init__(self):
        self._energy = None

    def notify(self, event):
        self._energy = event.energy()

    def draw(self):
        return str(self._energy.value())

Introducimos nuevos eventos a los que dar soporte:

    def test_handles_command_sent_event(self):
        event = PlayerSentCommand("command", "argument")

        printer = Printer()
        printer.notify(event)

        output = printer.draw()
        self.assertIn("command argument", output)

El evento:

class PlayerSentCommand:
    def __init__(self, command, argument):
        self._command = command
        self._argument = argument

    def name(self):
        return "player_sent_command"

    def command(self):
        return self._command

    def argument(self):
        return self._argument

La implementación que desarrollo es esta:

class Printer:
    def __init__(self):
        self._command = ""
        self._energy = ""

    def notify(self, event):
        if event.name() == "player_energy_changed":
            self._energy = str(event.energy().value())
        else:
            self._command = "{} {}".format(event.command(), event.argument())

    def draw(self):
        output = self._command
        output += self._energy
        return output

Obviamente, esto no es exactamente lo que espero mostrar en el juego real. Pero para eso tengo el puerto ShowOutput que, si recordamos usa un objeto Scene para mostrar el resultado. Podemos inyectarlo a Printer, de modo que en lugar de poblar sus propias variables, construye un objeto Scene para pasarlo a una implementación de ShowOutput.

Ya teníamos una implementación de ShowOutput para tests pero está en el mismo archivo del test en el que se usa ahora. Así que voy a reorganizar un poco el código para que sea más sencillo reutilizarlo. Aprovecho para organizar otros objetos de uso en los tests.

Una vez hecho esto, voy a modificar el test de Printer para que utilizar ShowOutput.

class PrinterAsObserverTestCase(unittest.TestCase):
    def setUp(self):
        self.show_output = FakeShowOutput()
        self.printer = Printer(self.show_output)

    def test_handles_energy_changed_event(self):
        event = PlayerEnergyChanged(EnergyUnit(50))

        self.printer.notify(event)
        self.printer.draw()
        self.assertIn("50", self.show_output.contents())

    def test_handles_different_energy_changed_event(self):
        event = PlayerEnergyChanged(EnergyUnit(60))

        self.printer.notify(event)
        self.printer.draw()
        self.assertIn("60", self.show_output.contents())

    def test_handles_command_sent_event(self):
        event = PlayerSentCommand("command", "argument")

        self.printer.notify(event)
        self.printer.draw()
        self.assertIn("command argument", self.show_output.contents())

Y, de momento, podemos implementar Printer de la siguiente forma, a falta de escuchar a los eventos que nos traen información de la descripción o el nombre de la celda:

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

    def notify(self, event):
        if event.name() == "player_energy_changed":
            self._energy = str(event.energy().value())
        else:
            self._command = "{} {}".format(event.command(), event.argument())

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

        return self._show_output.put(scene)

Puesto que he introducido el evento “player_sent_command” voy a modificar Player para notificarlo. Para eso también necesito tocar un poco Command, que tiene un cierto desorden en este momento:

class Command:

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

    def do(self, receiver):
        pass

    def __str__(self) -> str:
        return "You said: {} {}".format(self._name(), self._argument)

    def _name(self):
        pass

    def name(self):
        return self._name()

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

        return ""

    def _to_str(self):
        return "{} {}".format(self._name(), self._argument)

Y ahora Player quedaría así:

class Player:
    def __init__(self, starting_energy):
        self._energy = Energy(starting_energy)
        self._last_result = ActionResult.player_acted("I'm ready")
        self._observers = []

    @classmethod
    def awake(cls):
        return cls(EnergyUnit(100))

    @classmethod
    def awake_with_energy(cls, starting_energy):
        return cls(starting_energy)

    def do(self, command, receiver):
        self._last_result = command.do(receiver)
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
        self._energy.decrease(self._last_action_cost())
        self._notify_observers(PlayerEnergyChanged(self._energy.current()))
        self._last_result.set("energy", str(self._energy))

    def _last_action_cost(self):
        return self._last_result.get("cost")

    def last_result(self):
        return self._last_result

    def is_alive(self):
        return self._energy.is_alive()

    def has_won(self):
        return self._last_result.get("exited")

    def register(self, observer):
        self._observers.append(observer)

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

Voy a hacer un commit, ya que los tests pasan y refactorizaré varias cosas en Player, para que quede un código algo más limpio. Por ejemplo, el método do:

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

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

    def _update_energy(self):
        self._energy.decrease(self._last_action_cost())
        self._notify_observers(PlayerEnergyChanged(self._energy.current()))
        self._last_result.set("energy", str(self._energy))

Un problema que tengo ahora es que estoy cambiando de un diseño a otro y el código está confuso, con muchos métodos que tendrán que desaparecer. Por ello debería enfocarme en introducir los eventos que todavía me faltan y así limpiar todo el código que ahora me está molestando.

Tengo algunas dudas en este tema, ya que, Player podría emitirlos, pero sería más natural que Dungeon lo hiciese en algún caso.

Casi seguro que Player debería notificar un evento player_got_description, destinado a informar de las consecuencias de su acción. Mientras que Dungeon podría informar cuando player cambia de celda, lo que lo convierte en subject.

Así que vamos allá. Primero, me aseguro de que Player notifica este evento:

class PlayerGotDescription:
    def __init__(self, description):
        self._description = description

    def name(self):
        return "player_got_description"

    def description(self):
        return self._description

Cosa que comprobamos mediante un nuevo test:

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

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

Y que podemos implementar así en Player

    def _execute_command(self, command, receiver):
        self._last_result = command.do(receiver)
        self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
        self._notify_observers(PlayerGotDescription(self._last_result.get('message')))

Por último, tenemos que atender este comando en Printer.

    def test_handles_got_description_event(self):
        event = PlayerGotDescription("scene description")

        self.printer.notify(event)
        self.printer.draw()
        self.assertIn("scene description", self.show_output.contents())

Así que ampliamos sus capacidades de escucha:

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

    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()
        else:
            self._command = "{} {}".format(event.command(), event.argument())

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

        return self.show_output.put(scene)

El último evento que necesito que Printer atienda es algo así como player_moved. Este evento realmente ocurre en Dungeon, cuando cambia la celda actual (_current) como consecuencia del comando go.

Para hacer que Dungeon pueda emitir eventos tengo que añadir los métodos necesarios y registrar a Printer como observer también.

Para ello voy a sacar la lógica que necesito a una clase Subject, que usaré dentro de los objetos que quiero que soporten eventos.

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

    def register(self, observer):
        self._observers.append(observer)

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

Así queda Player:

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

    # Removed code

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

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

Y lo mismo con Dungeon. Hasta ahora no había tenido que testearla directamente. El test quedaría más o menos así:

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

Este sería el evento:

class PlayerMoved:
    def __init__(self, room):
        self._room = room

    def room(self):
        return self._room

    def name(self):
        return "player_moved"

Y aquí tenemos Dungeon siendo subject:

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))
        if result.get("destination") is not None:
            self._current = result.get("destination")
            self._notify_observers(PlayerMoved(self._current))
        result.set("title", self._current)
        return result

    # Removed code

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

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

Solo nos falta que Printer escuche el evento de Dungeon. Fíjate que ni siquiera necesito instanciar un Dungeon para este test, simplemente necesito el evento adecuado.

    def test_handles_player_moved_event(self):
        event = PlayerMoved("another room")

        self.printer.notify(event)
        self.printer.draw()
        self.assertIn("another room", self.show_output.contents())

Y con ello puedo completar toda una escena:

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()
        else:
            self._command = "{} {}".format(event.command(), event.argument())

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

        return self.show_output.put(scene)

Consolido los últimos cambios en un commit.

Integrando estos cambios

Voy a empezar a integrar los cambios de manera progresiva. El código no está del todo listo, pero quiero empezar a limpiar cositas:

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

    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 = Player.awake()
        player.register(self._printer)
        dungeon.register(self._printer)
        while player.is_alive() and not player.has_won():
            command = self._obtain_command()
            player.do(command, dungeon)
            self._printer.draw()

    # Removed code

Con este cambio, los tests pasan y el juego funciona sin problemas. Además, el bucle de juego está más limpio.

La parte más fea es la inicialización, ya que todavía tengo que llamar directamente a ShowOutput para mostrar la bienvenida. Además, el registro de Printer como subscriber queda un tanto extraño. Ya iremos con eso. Voy a empezar a limpiar cosas que no necesito en diversas partes del código.

Para ello voy a ir usando el método “Mikado”, borrando código que creo que ya no necesito y cambiando sus usos para ver si puedo prescindir de él.

Por ejemplo, en Command tengo demasiadas cosas que me molestan, sobre todo las relacionadas con obtener la representación en string del mismo.

class Command:

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

    def do(self, receiver):
        pass

    def __str__(self) -> str:
        return "You said: {} {}".format(self._name(), self._argument)

    def _name(self):
        pass

    def name(self):
        return self._name()

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

        return ""

    def _to_str(self):
        return "{} {}".format(self._name(), self._argument)

Empiezo borrando __str__. Fallan los tests en TestConsoleObtainUserCommand. Lo cierto es que estos test verifican que se obtiene el comando correcto, por lo que a l o mejor lo que podemos hacer es cambiarlos para chequear que se obtiene una instancia del comando deseado en función del input.

class TestConsoleObtainUserCommand(TestCase):
    def test_should_show_a_prompt(self):
        with patch('builtins.input', return_value="go north") as mock_input:
            obtain_user_command = ConsoleObtainUserCommand()
            command = obtain_user_command.command()
            self.assertIsInstance(command, GoCommand)
            self.assertEqual("What should I do? >", mock_input.call_args.args[0])

    def test_should_normalize_case_to_lowercase(self):
        with patch('builtins.input', return_value="go NORTH"):
            obtain_user_command = ConsoleObtainUserCommand()
            command = obtain_user_command.command()
            self.assertIsInstance(command, GoCommand)
            self.assertEqual("north", command.argument())

    def test_should_trim_spaces(self):
        with patch('builtins.input', return_value="  go north   "):
            obtain_user_command = ConsoleObtainUserCommand()
            command = obtain_user_command.command()
            self.assertIsInstance(command, GoCommand)
            self.assertEqual("north", command.argument())

    def test_should_normalize_middle_spaces(self):
        with patch('builtins.input', return_value="go      north"):
            obtain_user_command = ConsoleObtainUserCommand()
            command = obtain_user_command.command()
            self.assertIsInstance(command, GoCommand)
            self.assertEqual("north", command.argument())

Y ya puedo eliminar definitivamente Command.__str__.

A continuación, quiero hacer lo mismo con Command._to_str. Si lo quito fallan varios tests, pero observo que la razón es que necesito añadir la clave “command” en ActionResult.

Pero en el código ya no la uso porque el evento PlayerSentCommand obtiene su información de otro lado. Simplemente, suprimo todos los usos de este método y compruebo que no lo necesito para nada.

En Command me queda name y _name que hacen lo mismo. El único uso de _name que queda es en el CommandMatcher que uso en algún test para comprobar que dos comandos son iguales. De momento, cambio CommandMatcher para que use la versión pública, que ya es un avance.

class CommandMatcher:
    def __init__(self, expected):
        self.expected = expected

    def __eq__(self, other):
        return self.expected.name() == other.name() and \
               self.expected.argument() == other.argument()

En el resto de comandos, tendría que cambiar _name por name.

class Command:

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

    def do(self, receiver):
        pass

    def name(self):
        return None

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

        return ""

Más limpieza. Me doy cuenta que Dungeon sigue anotando en ActionResult el nombre de la celda actual, que ya se comunica mediante un evento. Yo creo que podríamos borrar esas líneas sin muchos problemas:

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))
    result.set("title", self._current)
    return result

Efectivamente, si borro las dos líneas en las que ocurre eso, no se afecta a los tests ni al juego en producción.

Este detalle es interesante porque estamos vaciando ActionResult y me lleva a pensar el modo en que estamos pasando el coste de cada comando. Creo que el diseño podría mejorar si lo hacemos de otra forma, pero aún no tengo del todo claro como hacerlo.

Finalmente, querría limpiar Player, pero aquí me encuentro con algunos problemas más interesantes. Player expone métodos que usa Application para saber si el bucle del juego debe continuar o terminar. ¿No podríamos conceptualizarlos como eventos? Pero, en ese caso, ¿Quién los escucharía? ¿Application? O quizá debamos volver a introducir Game.

Creo que lo voy a dejar para la próxima entrega.

Próximos pasos

En esta entrega no he avanzado en introducir funcionalidad en el juego, pero me ha parecido importante buscar un mejor diseño. Mi primera aproximación mejoró puntualmente algunas cosas, pero empeoró el diseño general.

Sin embargo, me hizo caer en la cuenta de que eran posibles otras aproximaciones. El gran problema con este juego es que pasan cosas de las que algunos objetos tienen que enterarse, aunque no tengan una relación directa con el objeto en donde ocurren. Aquí es donde entra el sistema de eventos.

En la próxima entrega, profundizaré en esta aproximación para resolver algunos de los problemas que todavía nos quedan pendientes.

December 3, 2022

Etiquetas: python   good-practices   dungeon   design-patterns  

Temas

good-practices

refactoring

php

testing

tdd

design-patterns

python

blogtober19

design-principles

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

hexagonal

tips

software-design

ddd

books

bbdd

soft-skills

pulpoCon

oop

javascript

api

typescript

sql

ethics

agile

swift

java