Después de darle unas vueltas creo que lo siguiente que me gustaría que pasase en Dungeon es que para salir haya que haber conseguido encontrar un tesoro.
Bueno, puede no ser un tesoro. El caso es que para dar interés al juego y que sea necesario recorrer la mazmorra antes de salir, pienso que se puede poner una misión a la jugadora. Esta misión sería encontrar un objeto o varios.
Al llegar a la puerta de salida tendría que verificarse que la misión se ha cumplido para poder salir. Si no, es como una puerta cerrada con llave o una pared.
¿Qué necesitamos preparar para esto?
- El comando
collect
porque hemos dicho que ese es el que nos permitirá coger objetos que encontremos y guardarlos. Para guardarlos, necesitaremos algún tipo deBag
o colección de objetos que vivirá dentro dePlayer
y que tendrá una capacidad limitada. - También vamos a necesitar un comando
drop
, ya que en ocasiones querremos deshacernos de los objetos que llevamos y dejarlos en alguna celda. - Que los objetos
Exit
puedan interactuar conPlayer
. Esto es,Exit
es una pared que responde al métodogo
permitiendo la salida de la mazmorra. Necesitamos introducir una modificación que le permita poner condiciones para permitir el paso. Una forma de hacer esto es mediante un patrónSpecification
que pueda usar el contenido deBag
y averiguar si contiene el objeto u objetos que hacen de llave. - Este mismo modelo se puede aplicar a cualquier puerta que queramos cerrar o abrir con llave. Lo único que hace especial a Exit es que es la puerta de salida de la mazmorra.
Esta nueva feature abre la puerta bastantes temas interesantes:
- Herencia y polimorfismo: necesitaremos introducir nuevos tipos de objetos, ahora solo tenemos uno, con distintos comportamientos. Y también nuevas
Door
yExit
con nuevos comportamientos. - Mejorar la identificación de objetos y separarla de su descripción.
- Al incorporar nuevos comandos, podríamos considerar una forma de auto-descubrimiento en la factoría.
- Introducir más objetos en la mazmorra, nuevos tipos de puertas y “misiones”, nos puede llevar a reconsiderar algún lenguaje de configuración de las mazmorras.
La parte buena es que creo que el código está ahora en mejor estado para introducir nuevas funcionalidades.
Por supuesto, ahora no vamos a intentar abordarlo todo a la vez. Así que vamos a intentar rebanar esta nueva característica del juego.
Me parece bastante obvio que implementar el comando collect
es lo primero que deberíamos estar haciendo. Una vez que Player
es capaz de coger objetos y transportarlos.
Hay que recordar también que Player
tendría que poder coger (get
) o usar objetos que lleva en la bolsa. Bien permitiendo que get
pueda aplicarse a objetos de la bolsa, o bien haciendo un get implícito antes. Esto podría implementarse ahora o más tarde.
Después podríamos incorporar nuevos tipos de objetos. De momento pueden no tener uso, pero se podrían coleccionar. Por ejemplo, objetos Key
para abrir puertas.
El tercer paso sería implementar una versión de Exit
que pueda chequear si Player
lleva consigo la Key
adecuada. Aquí tendríamos que introducir una manera de definir esto (se me ocurre que esa LockedExit
guarde una referencia al objeto u objetos Key
que la abre.
Antes de continuar, querría señalar que en los tests veremos varios decoradores que he ido introduciendo para hacerlos más expresivos y concisos.
Implementando el comando Collect
Pero antes un refactor preparatorio
En principio CollectCommand
solo tendrá que llamar al método collect
en su receiver que será normalmente Player
. Otros comandos se aplican sobre otros receivers. Mientras escribo esto, pienso que un nombre mejor que receiver sería handler…
En cualquier caso, normalmente antes hacer nada me gusta ver el contexto. En este caso, este método de Player
;
def _execute_command(self, command, receiver):
if command.name() == "use":
command.do(self)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
return
command.do(receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
Dos cosas:
- El
if
sugiere polimorfismo. Cuando quiera introducir el commandocollect
, tendré que añadirlo a la lista de comandos que se aplican aPlayer
. Pero es un smell, ya que estoy consultando un tipo para decidir qué hacer con el objeto. - La duplicación. Lo único que cambia en cada rama del
if
es el objeto que se pasa al comando. Esto se ve más fácilmente si añadimoselse
:
def _execute_command(self, command, receiver):
if command.name() == "use":
command.do(self)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
else:
command.do(receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
Podríamos refactorizarlo así, en dos pasos:
def _execute_command(self, command, receiver):
if command.name() == "use":
effective_receiver = self
command.do(effective_receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
else:
effective_receiver = receiver
command.do(effective_receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
Para acabar con:
def _execute_command(self, command, receiver):
if command.name() == "use":
effective_receiver = self
else:
effective_receiver = receiver
command.do(effective_receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
Que en Python puede expresarse así:
def _execute_command(self, command, receiver):
effective_receiver = self if command.name() == "use" else receiver
command.do(effective_receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
No es la solución final, pero deja este espacio un poco mejor.
En realidad, lo que queremos es poder hacer una especie de cadena de responsabilidad y pasar el comando tanto a self
como a cualquier receiver que tengamos. Cada uno de ellos debería ver si puede aplicarse o no en el receiver.
Una posible forma de hacer esto es que antes de ejecutar command.do
se verifique si self
lo puede ejecutar. Si no, lo pasa a receiver
. O sea, algo así:
def _execute_command(self, command, receiver):
effective_receiver = self if hasattr(self, command.name()) else receiver
command.do(effective_receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
Esto está mejor porque ahora no necesitamos añadir una lista creciente de comandos que tendríamos que actualizar para chequear a quien tenemos que enviar el comando. De este modo, este método de Player
cumple mejor con Open/Close.
Como side note, he tenido que hacer un pequeño arreglo en el FakeCommand
de test para que devuelva su nombre.
class FakeCommand(Command):
def __init__(self, energy_consumption):
self._energy_consumption = energy_consumption
def cost(self):
return self._energy_consumption
def name(self):
return "fake"
Con todo, no me convence del todo la solución. Sigo preguntándole al objeto algo para decidir cómo usarlo. Esto viola Tell, don’t ask y me gustaría evitarlo.
Y puedo hacerlo con algo de meta programación. Al fin y al cabo, tenemos una especie de convención que nos dice que todos los receivers que quieran ejecutar un comando deben tener un método cuyo nombre sea igual al del comando. Simplemente Command
se ejecutará si el receiver tiene el método adecuado.
Usando esta información, podríamos hacer esta modificación:
class Command:
def __init__(self, argument):
self._argument = argument
def do(self, receiver):
if hasattr(receiver, self.name()):
getattr(receiver, self.name())(self.argument())
def name(self):
return ""
def cost(self):
return EnergyUnit(1)
def argument(self):
if hasattr(self, "_argument"):
return self._argument
return ""
De este modo, no necesitamos implementar el método do
en ningún objeto de tipo Command
. Por ejemplo:
class UseCommand(Command):
def __init__(self, argument):
super().__init__(argument)
def name(self):
return "use"
Todos los tests siguen pasando. Si necesitase personalizar el comportamiento de uno de estos objetos no tendría más que sobreescribir el método do
.
¿Y cómo afecta esto a Player
? Básicamente ahora podemos enviar el comando recibido a sí mismo o al receiver, en la confianza de que lo ejecutará aquel de los dos que le de soporte.
def _execute_command(self, command, receiver):
command.do(self)
command.do(receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
Este refactor nos facilita introducir nuevos comandos ya que no tenemos que tocar nada en Player
aparte de añadir un método en caso de que le vayamos a dar soporte.
No obstante, sí que tendríamos que añadirlo en CommandFactory
para que se pueda instanciar. Y como cada vez son más comandos los que necesitamos, puede que nos venga bien otro poco de auto-magia, digo, de meta programación.
Una factoría que descubre los comandos
No soy especialmente fan de estas técnicas, pero manteniéndolas en el contexto adecuado creo que pueden ser de ayuda.
La cuestión es que Python ofrece utilidades suficientes como para que los objetos Command
puedan ser descubiertos por CommandFactory
e instanciados siguiendo unas convenciones simples. De este modo, cada vez que introduzca un comando nuevo podré usarlo sin necesidad de tocar la factoría, extendiendo la funcionalidad de Dungeon simplemente añadiendo código.
La funcionalidad de Python sobre la que se va a basar este autodescubrimiento de comandos es la posibilidad de importar módulos e instanciar clases dinámicamente. Lo único que necesito es respetar algunas convenciones:
- Los comandos se guardan en el package
dungeon.app.command.commands
. - Cada comando vive en un módulo llamado
[nombre_del_comando]_command
. - El nombre de la clase para instanciar es
[Nombre]Command
y recibe un parámetro.
Ahora mismo me molestan un par de cosas de CommandFactory
. Principalmente, el hecho de que se use de forma estática. Me interesaría que tengo un motor de instanciación de comandos inyectable, de este modo puedo aislar convenientemente la parte meta programada.
Creo que puedo arreglar esto mediante una técnica de cambio paralelo. Introduzco el código en la forma nueva en que lo quiero usar primero y luego reemplazo el uso viejo.
Vamos primero a desarrollar el motor auto-mágico con un test que pruebe que puedo instanciar un comando:
class TestCommandFactoryAutodiscoveryEngine(TestCase):
def test_autodiscover_commands(self):
autodiscover = Autodiscover("dungeon.app.command.commands")
command = autodiscover.by_name("go", "argument")
self.assertIsInstance(command, GoCommand)
En principio quiero hacerlo configurable, pasándole la ruta en la que buscar los comandos. La idea es que con el comando que escribe la usuaria podamos encontrar el comando deseado.
Implementar esto no es muy complicado. import_module
importa un módulo definido dinámicamente, mientras que podemos instanciar una clase obteniéndola del módulo que la contiene.
class Autodiscover:
def __init__(self, path_to_commands):
self._path_to_commands = path_to_commands
def by_name(self, command, argument):
module = importlib.import_module(self._path_to_commands + "." + command + "_command")
command_class = getattr(module, command.capitalize() + "Command")
return command_class(argument)
Para aclarar un poco el código:
class Autodiscover:
def __init__(self, path_to_commands):
self._path_to_commands = path_to_commands
def by_name(self, command, argument):
module = import_module("{path}.{command}_command".format(path=self._path_to_commands, command=command))
command_class = getattr(module, "{name}Command".format(name=command.capitalize()))
return command_class(argument)
Una cosa que me interesa es obtener un InvalidCommand
en el caso de que no se encuentre el comando buscado, bien por un error o porque no está definido.
def test_fallback_to_invalid_if_no_command_found(self):
autodiscover = Autodiscover("dungeon.app.command.commands")
command = autodiscover.by_name("yadayadayada", "argument")
self.assertIsInstance(command, InvalidCommand)
Esto debería ser suficiente:
class Autodiscover:
def __init__(self, path_to_commands):
self._path_to_commands = path_to_commands
def by_name(self, command, argument):
try:
module = import_module("{path}.{command}_command".format(path=self._path_to_commands, command=command))
command_class = getattr(module, "{name}Command".format(name=command.capitalize()))
except ModuleNotFoundError:
command_class = InvalidCommand
return command_class(argument)
Ahora que tenemos un motor de auto descubrimiento de comandos, voy a extraer el motor manual.
class Custom:
def by_name(self, command, argument):
if command == "go":
return GoCommand(argument)
if command == "look":
return LookCommand(argument)
if command == "get":
return GetCommand(argument)
if command == "use":
return UseCommand(argument)
return InvalidCommand("{} {}".format(command, argument))
Y ahora hay que cambiar CommandFactory
para que se le puedan inyectar los motores. Primero, la abstracción:
class CommandFactoryEngine:
def by_name(self, command, argument):
pass
Y quedaría así:
class CommandFactory:
def __init__(self):
self._engine = Custom()
def from_user_input(self, user_input):
try:
command, argument = user_input.split(" ", 1)
except ValueError:
command = user_input
argument = "around"
return self._engine.by_name(command, argument)
Esto va a romper el uso que tenemos ahora, así que vamos a modificarlo:
class ConsoleObtainUserCommand(ObtainUserCommand):
def command(self):
raw = input("What should I do? >")
user_input = " ".join(raw.lower().strip().split())
factory = CommandFactory()
return factory.from_user_input(user_input)
Hay que hacer el mismo cambio en varios tests, así que una vez que todos vuelven a pasar, consolido estos últimos cambios antes de dar el último paso. Hago que engine
sea un parámetro opcional para construir CommandFactory
.
class CommandFactory:
def __init__(self, engine=Custom()):
self._engine = engine
def from_user_input(self, user_input):
try:
command, argument = user_input.split(" ", 1)
except ValueError:
command = user_input
argument = "around"
return self._engine.by_name(command, argument)
Y finalmente inyecto el motor de auto descubrimiento:
class ConsoleObtainUserCommand(ObtainUserCommand):
def command(self):
raw = input("What should I do? >")
user_input = " ".join(raw.lower().strip().split())
factory = CommandFactory(Autodiscover("dungeon.app.command.commands"))
return factory.from_user_input(user_input)
Lo suyo sería seguir el proceso e inyectar CommandFactory
.
class ConsoleObtainUserCommand(ObtainUserCommand):
def __init__(self, command_factory):
self._command_factory = command_factory
def command(self):
raw = input("What should I do? >")
user_input = " ".join(raw.lower().strip().split())
return self._command_factory.from_user_input(user_input)
Hay que hacer una modificación en el entry_point y en algún test, pero es sencillo:
def main(args=None):
if args is None:
args = sys.argv[1:]
toggles = Toggles()
factory = CommandFactory(Autodiscover("dungeon.app.command.commands"))
obtain_user_command = ConsoleObtainUserCommand(factory)
application = Application(obtain_user_command, RichConsoleShowOutput(), DungeonFactory(), toggles)
application.run()
if __name__ == "__main__":
sys.exit(main())
Este ha sido un largo rodeo para poder empezar a implementar CollectCommand
. Pero ahora nos bastaría con añadir la clase para que esté disponible.
Ahora sí, vamos a coleccionar
En principio CollectCommand
invocará el método collect
en Player
. Ahora no existe, pero como ya hemos trabajando en eso, no debería pasar nada si lo introducimos e intentamos usarlo:
class CollectCommand(Command):
def __init__(self, argument):
super().__init__(argument)
def name(self):
return "collect"
De hecho, si lo usamos no pasará nada porque ni Player
no Dungeon
tienen un método para usarlo. Sin embargo, como podemos comprobar si lo usamos, consume energía, prueba de que se ha invocado.
Welcome to the Dungeon
--------------------------------------
Remaining energy: 100
======================================
What should I do? >collect food
You said: collect food
Welcome to the Dungeon
--------------------------------------
Remaining energy: 99
======================================
Así que estamos listas para implementarlo. El comando collect
es similar a get
, con la diferencia de que Player
añade el objeto a una colección que podríamos llamar Bag
o Backpack
(mochila). get
es un comando que entiende Dungeon
porque es Dungeon
quien conoce los objetos disponibles. Comunica el resultado enviando un evento que escucha Player
. Veámoslo aquí:
class Dungeon:
# Removed code
def get(self, thing_name):
thing = self._current_room().get(thing_name)
self._notify_observers(PlayerGotThing(thing))
Así que podemos hacer algo similar para collect
. Veamos como desarrollarlo en TDD. Este primer test resultará familiar, porque es el mismo que usamos para GetCommand
. Si la jugadora colecciona un objeto, éste ya no estará en la celda. Es un primer paso.
class CollectingThingsTestCase(unittest.TestCase):
def setUp(self):
self.builder = DungeonBuilder()
self.observer = FakeObserver()
self.player = Player()
self.player.register(self.observer)
@expect_event_containing("player_got_description", "description", "There are no objects")
def test_player_collect_object_removes_from_room(self):
dungeon = self.dungeon_with_object(Thing("Food"))
dungeon.register(self.observer)
self.player.awake_in(dungeon)
self.player.do(CollectCommand("food"))
dungeon.look('objects')
def dungeon_with_object(self, thing=Thing("Food")):
builder = DungeonBuilder()
builder.add('start')
builder.put('start', thing)
return builder.build()
Así que empezamos con esto en Dungeon
:
def collect(self, thing_name):
self._current_room().get(thing_name)
El siguiente paso es asegurar que Dungeon publica el evento adecuado:
@expect_event("player_collected_thing")
def test_dungeon_raises_event(self):
dungeon = self.dungeon_with_object(Thing("Food"))
dungeon.register(self.observer)
self.player.awake_in(dungeon)
self.player.do(CollectCommand("food"))
Este es el evento:
class PlayerCollectedThing:
def __init__(self, thing):
self._thing = thing
def name(self):
return "player_collected_thing"
def thing(self):
return self._thing
Y esto es lo que tiene que hacer Dungeon
:
def collect(self, thing_name):
thing = self._current_room().get(thing_name)
self._notify_observers(PlayerCollectedThing(thing))
La segunda parte es que Player
extraiga el objeto del evento y lo añada a su Backpack
. El problema puede ser cómo saber que se ha producido ese cambio. La respuesta podría ser usar otro evento para eso. Entre otras cosas porque nos interesa poder añadir esa información en la pantalla y ahora no le damos soporte. Un evento BackpackChanged
podría llevar información del contenido de la mochila para que lo utilice Printer
. Con esto podríamos empezar a escribir el test.
@expect_event_containing("backpack_changed", "content", "Food")
def test_player_added_item_to_backpack(self):
dungeon = self.dungeon_with_object(Thing("Food"))
dungeon.register(self.observer)
self.player.awake_in(dungeon)
self.player.do(CollectCommand("food"))
Queremos algo así:
class Player:
# Removed code
def notify(self, event):
if "player_got_thing" == event.name():
self._holds = event.thing()
if "player_collected_thing" == event.name():
self._backpack.append(event.thing())
self._notify_observers(BackPackChanged(self._backpack.content()))
El evento quedará más o menos así:
class BackPackChanged:
def __init__(self, content):
self._content = content
def name(self):
return "backpack_changed"
def content(self):
return self._content
Y, de momento, ponemos toda la lógica que necesitamos en Backpack
:
class Backpack:
def __init__(self):
self._items = dict()
def append(self, item):
self._items[item.name()] = item
def content(self):
content = []
for key, item in self._items.items():
content.append(item.name())
return ", ".join(content)
Con esto queda definido el comportamiento de coleccionar objetos. Nos quedaría hacer que Printer
conozca este evento y sepa hacer algo con él.
class Printer:
# Removed code
def notify(self, event):
if event.name() == "player_energy_changed":
self._energy = str(event.energy().value())
# Removed code
elif event.name() == "backpack_changed":
self._description = "Your backpack now contains: {}".format(event.content())
Y así se puede ver:
What should I do? >collect Food
You said: collect food
10
--------------------------------------
Your backpack now contains: Food
Remaining energy: 70
======================================
Y parece que ya podemos recolectar objetos. Eso sí, nuestra mochila ahora mismo es ilimitada, auqnue no es problema porque no hay muchos objetos para recoger.
Tener objetos que se puedan recolectar y usar
Tener objetos guardados en la mochila no es útil si no los puedes usar. Bueno, para esta iteración nos valdría con que la puerta Exit
pudiese verificarlos. Pero imagina la siguiente situación: coges un objeto “comida” y lo guardas… pero no lo puedes recuperar para comerlo cuando necesites un extra de energía.
En otras palabras, los comandos get
y use
deberían poder aplicarse a objetos que están en la mochila.
Por otro lado, los objetos de comida no deberían ser los únicos disponibles. De hecho, únicamente hemos definido un único tipo de objeto y no tenemos más opciones. Además de eso, recordemos que tenemos un temilla pendiente con la distinción entre objeto, su nombre y su identificador.
Personalmente, creo que lo primero sería resolver el problema de usar objetos de la mochila. La razón es porque resultaría muy frustrante para la jugadora una situación como la descrita hace un momento: tener un objeto que necesitas, pero que no puedes utilizar.
Lo interesante es que debería ser muy fácil de implementar gracias al refactor que hicimos al principio de esta entrega. Nos bastaría con implementar el método get
en Player
, de modo que si tienes un objeto adecuado en la mochila pases a tenerlo en la mano. Otra opción sería añadir al método use
la capacidad de usar objetos que estén en la mochila.
Es importante recordar algunas reglas:
- Cuando coges (
get
) un objeto deja de estar en su ubicación original (la celda o la mochila). - Hay objetos que se consumen al usarlos.
Vamos con get
. Si implementamos get
en Player
se ejecutará y como queremos que coja objetos de la mochila, lo sabremos porque se lanza el evento backpack_changed
.
Aprovecho que tenemos un test dedicado a Player
cogiendo cosas, así que nos viene perfecto:, para añadir esta versión. Lo primero que vamos a verificar es que sostenga el objeto en la mano.
def test_player_get_object_from_backpack_and_holds(self):
thing = Thing("Food")
dungeon = self.dungeon_with_object(thing)
self.player.awake_in(dungeon)
self.player.do(CollectCommand("food"))
self.player.do(GetCommand("food"))
self.assertEqual(thing, self.player.holds())
Y allá vamos:
def test_player_get_object_and_holds(self):
thing = Thing("Food")
dungeon = self.dungeon_with_object(thing)
self.player.awake_in(dungeon)
self.player.do(GetCommand("food"))
self.assertEqual(thing, self.player.holds())
Ha habido que hacer varios arreglos para conseguir el funcionamiento correcto de este comando. ¿Tenemos un problema de shotgun surgery?
Decimos que tenemos un problema de shotgun surgery cuando para introducir un cambio tenemos que tocar en muchos lugares del código. Esto indicaría falta de cohesión. Pero vamos a intentar revisar cuales han sido los problemas que hemos encontrado para analizarlo y ver en qué nos hemos equivocado.
Por ejemplo, un problema es que tanto las colecciones Things como Backpack fallan si el objeto que buscan no está, por lo que hay que controlar antes de devolverlos que existen. Aquí puedes ver la corrección:
class Things:
# Removed code
def get(self, thing_name):
if thing_name in self._things.keys():
return self._things.pop(thing_name.lower())
class Backpack:
# Removed code
def get(self, thing_name):
if thing_name in self._items.keys():
return self._items.pop(thing_name.lower())
Hay muchas similitudes en el funcionamiento de Things
y Backpack
, pero son conceptos de negocio distintos con algunas reglas diferentes. Things
representa los objetos que hay en una habitación, mientras que Backpack
representa los objetos que lleva la jugadora. En la primera colección no hay un límite preciso de la cantidad de objetos, mientras que en la segunda, sí queremos que lo haya.
El hecho de que tengamos que hacer la misma corrección viene de no haber considerado ese caso de uso: intentar coger un objeto que no está.
Este es un caso de duplicación de código en el que es fácil buscar la abstracción equivocada. En realidad, si queremos reducir esa duplicación sería a través de introducir una nueva estructura de datos, basada en el diccionario y adaptada a nuestras necesidades. Pero no una abstracción de negocio: no necesitamos un ThingsContainer
que nos sirva para representar Things
y Backpack
, sino una estructura de diccionario enriquecida para soportar límite de capacidad, por un lado, y permanecer silenciosa si intentamos extraer un objeto que no existe.
Pero antes de ver si merece la pena atacar eso, tenemos que revisar un test que ha empezado a fallar a raíz de este cambio:
@expect_event_equal("player_energy_changed", "energy", EnergyUnit(58))
def test_using_food_makes_player_increase_energy(self):
dungeon = self.dungeon_with_object(Thing("Food"))
player = Player(EnergyUnit(50))
player.awake_in(dungeon)
player.register(self.observer)
player.do(GetCommand("Food"))
player.do(UseCommand("food"))
self.assertIsNone(player.holds())
Un problema de haber usado un decorador para este test es que es un poco más difícil entender el error, que es que no coinciden los valores de energía con lo esperado. Por el momento, vamos a cambiarlo para ser más explícitos:
def test_using_food_makes_player_increase_energy(self):
dungeon = self.dungeon_with_object(Thing("Food"))
player = Player(EnergyUnit(50))
player.awake_in(dungeon)
player.register(self.observer)
player.do(GetCommand("Food"))
player.do(UseCommand("food"))
last = self.observer.last('player_energy_changed')
self.assertEqual(58, last.energy().value())
self.assertIsNone(player.holds())
El error revela que no se está incrementando la energía al consumir la comida. ¿Por qué?
Expected :58
Actual :48
Porque no encuentra la comida por la diferencia de caso. De nuevo tenemos que corregir dos veces:
class Things:
# Removed code
def get(self, thing_name):
if thing_name.lower() in self._things.keys():
return self._things.pop(thing_name.lower())
class Backpack:
# Removed code
def get(self, thing_name):
if thing_name.lower() in self._items.keys():
return self._items.pop(thing_name.lower())
Obviamente, la causa raíz es la falta de consistencia de thing_name
por usar primitivos y que aún no hemos arreglado. La duplicación de código contribuye a que el problema sea más difícil de identificar y probablemente nuestra vida será mejor abordándola correctamente.
Nos queda un último test para asegurar el comportamiento verificando que el objeto ha sido retirado de la mochila:
@expect_event_containing("backpack_changed", "content", "")
def test_player_get_object_removes_from_backpack(self):
dungeon = self.dungeon_with_object(Thing("Food"))
dungeon.register(self.observer)
self.player.awake_in(dungeon)
self.player.do(CollectCommand("food"))
self.player.do(GetCommand("food"))
El test pasa con el código que ya tenemos. Gracias a ello, hemos completado la implementación del comando get
para que mire también en la mochila.
Casos a los que prestar atención
Hay algunas cuestiones de negocio en esta funcionalidad que tenemos que abordar en algún momento:
- Si ya tengo un objeto en la mano ¿A dónde va si queremos coger otro? Ahora mismo, el objeto en la mano desaparecería. Teniendo en cuenta que ahora sabemos de dónde viene el objeto (si viene de la mochila o de la habitación), simplemente podríamos intercambiarlo el que estamos sosteniendo con el nuevo en su ubicación. Creo que puede ser un punto de partida suficiente.
- El límite de objetos en la mochila y qué consecuencias tiene. En principio si se alcanza el límite no se pueden poner objetos en la mochila.
- Una de estas consecuencias es que necesitamos implementar el comando
drop
, para quitar objetos de la mochila (o de la mano) y dejarlos en la habitación actual.
Así que vamos a implementar nuestra primera preocupación; qué hacer con el objeto que tenemos en la mano si queremos coger otro.
Vamos a empezar con un test en el que recogemos dos objetos de la habitación consecutivamente. Queremos quedarnos con el último en la mano y devolver el primero a la celda.
Este test pasa sin hacer ningún cambio. Tiene sentido porque el último objeto es que el que sostenemos en la mano. Realmente, lo que nos interesa es saber que el primero ha sido devuelto a la celda.
def test_player_get_two_objects_and_holds_the_last_one(self):
first = Thing("Food")
second = Thing("Sword")
dungeon = self.dungeon_with_objects(first, second)
self.player.awake_in(dungeon)
self.player.do(GetCommand("food"))
self.player.do(GetCommand("sword"))
self.assertEqual(second, self.player.holds())
Así que lo modifico para averiguarlo.
@expect_event_containing("player_got_description", "description", "Food")
def test_player_get_two_objects_and_holds_the_last_one(self):
first = Thing("Food")
second = Thing("Sword")
dungeon = self.dungeon_with_objects(first, second)
dungeon.register(self.observer)
self.player.awake_in(dungeon)
self.player.do(GetCommand("food"))
self.player.do(GetCommand("sword"))
self.player.do(LookCommand("objects"))
self.assertEqual(second, self.player.holds())
Este test falla como es debido:
AssertionError: 'Food' not found in 'There are no objects\n'
Así que vamos a ver cómo lo podemos implementar. En Player deberíamos tocar aquí:
def notify(self, event):
if "player_got_thing" == event.name():
self._holds = event.thing()
if "player_collected_thing" == event.name():
self._backpack.append(event.thing())
self._notify_observers(BackPackChanged(self._backpack.content()))
La forma más fácil de hacer esto sería implementar un método drop
en Dungeon
. Teniendo en cuenta que Player
tiene una referencia a Dungeon
en _receiver
es la manera más fácil de hacerlo. No estoy seguro de que sea la más correcta, pero funcionaría.
def notify(self, event):
if "player_got_thing" == event.name():
if self._holds is not None:
self._receiver.drop(self._holds)
self._holds = None
self._holds = event.thing()
if "player_collected_thing" == event.name():
self._backpack.append(event.thing())
self._notify_observers(BackPackChanged(self._backpack.content()))
Y en Dungeon
:
def drop(self, thing):
self._current_room().put(thing)
El test no pasa y es por algo que pasa en Player
. Cuando intentamos aplicar el comando get
, Player
pone a None
self._holds
incondicionalmente si no encuentra el objeto deseado en Backpack
. Solo debería cambiar el contenido de self._holds
si ha encontrado el objeto en la mochila.
def get(self, thing_name):
self._holds = self._backpack.get(thing_name)
Y esto se resuelve así:
def get(self, thing_name):
thing = self._backpack.get(thing_name)
if thing is not None:
self._holds = thing
Ahora tendríamos que hacer lo mismo con objetos que tengamos en la mochila. El test coloca (collect) un par de objetos en la mochila primero y luego los coge para usarlos uno por por uno. En la mochila debería quedar Sword
y en la mano Food
.
@expect_event_equal("backpack_changed", "content", "Sword")
def test_player_collects_two_objects_and_holds_the_last_one(self):
food = Thing("Food")
sword = Thing("Sword")
dungeon = self.dungeon_with_objects(food, sword)
dungeon.register(self.observer)
self.player.awake_in(dungeon)
self.player.do(CollectCommand("food"))
self.player.do(CollectCommand("sword"))
self.player.do(GetCommand("sword"))
self.player.do(GetCommand("food"))
self.assertEqual(food, self.player.holds())
El test falla de la forma esperada, en la mochila solo debe quedar Sword
. Esto es lo que nos dice el evento lanzado tras el último CollectCommand
, porque lo cierto es que no se ha enviado ninguno después y si miramos el contenido de Backpack
usando el depurador, vemos que está vacía.
Expected :Sword
Actual :Food, Sword
Me gustaría contar con un test más directo de Backpack
porque de hecho el test es un poco engañoso, pero con los eventos debería ser suficiente.
La cuestión clave en este caso es que deberíamos lanzar un evento backpack_changed
cuando quitamos un objeto de la mochila. Si aplicamos este cambio el test fallará de una manera diferente.
def get(self, thing_name):
thing = self._backpack.get(thing_name)
if thing is not None:
self._holds = thing
self._notify_observers(BackPackChanged(self._backpack.content()))
Así es como falla el test, y describe mucho mejor lo que necesitamos:
Expected :Sword
Actual :
Ahora el test dice que no queda nada en la mochila y eso es exactamente lo que necesitábamos, porque el comportamiento actual es que al coger algo nuevo de la mochila machaca lo que tengamos en la mano.
Lo que queda ahora, entonces, es comprobar si tenemos algo en la mano y ponerlo en la mochila y entonces coger el objeto deseado:
def get(self, thing_name):
thing = self._backpack.get(thing_name)
if thing is not None:
if self._holds is not None:
self._backpack.append(self._holds)
self._holds = None
self._holds = thing
self._notify_observers(BackPackChanged(self._backpack.content()))
Y con esto pasamos el test.
Tal como está ahora lo que ocurre es que “soltamos” el objeto que teníamos en la mano en el lugar de donde cogemos el nuevo. Si es en la celda, lo soltamos en la celda. Si lo sacamos de la mochila, ponemos el anterior en la mochila. Por el momento me parece una buena solución.
Próximos pasos
Quedan todavía partes de esta iteración por desarrollar, como son añadir nuevos objetos en la mazmorra e introducir una variante de Exit
que requiera una llave específica para salir. Pero como el artículo se alarga, voy a dejarlo aquí.
Antes de terminar sí que me gustaría hacer algunos refactors básicos en sitios diversos.
Por ejemplo:
class Player:
# Removed code
def notify(self, event):
if "player_got_thing" == event.name():
self._do_get_thing(event)
if "player_collected_thing" == event.name():
self.do_collect_thing(event)
def do_collect_thing(self, event):
self._backpack.append(event.thing())
self._notify_observers(BackPackChanged(self._backpack.content()))
def _do_get_thing(self, event):
if self._holds is not None:
self._receiver.drop(self._holds)
self._holds = None
self._holds = event.thing()
Otra cosa que voy a hacer es introducir una clase base para los eventos. Hasta ahora no me ha hecho falta, pero me gustaría depender menos del nombre como string.
class Event:
def name(self):
pass
def of_type(self, cls):
return isinstance(self, cls)
class PlayerDied(Event):
def __init__(self):
pass
def name(self):
return "player_died"
El método of_type
, me permite hacer cosas como esta:
class Player:
# Removed code
def notify(self, event):
if event.of_type(PlayerGotThing):
self._do_get_thing(event)
if event.of_type(PlayerCollectedThing):
self.do_collect_thing(event)
Con este cambio, la gestión de eventos es más sólida y debería ser menos ambigua. Tengo que cambiarlo en todos los lugares, y también en los decoradores de los tests.