Ya son horas de empezar a añadir prestaciones nuevas, pero antes deberíamos terminar de poner un poco de orden en algunas partes del código.
Más eventos
En el capítulo anterior señalaba que tendría sentido lanzar el evento PlayerMoved
desde Door
, lo que nos ayudaría a reducir el uso de ActionResult
, que quedaría limitado básicamente a comunicar algunos mensajes… y no todos.
def go(self, direction):
result = self._current_room().go(Dir(direction))
if result.get("destination") is not None:
self._current = result.get("destination")
self._notify_observers(PlayerMoved(self._current))
return result
En cualquier caso, Dungeon
se convertiría también en observer, lo que puede quedar un poco raro dado que ahora mismo también actúa como Subject
. Es decir, tiene que escuchar el evento PlayerMoved
para actualizar la referencia a la habitación actual que se guarda en Dungeon._current
.
Creo que ahora mismo los tests existentes garantizarían que el cambio podría funcionar, así que vamos a hacerlo directamente.
class Door(Wall):
def __init__(self, destination):
self._destination = destination
self._subject = Subject()
def go(self):
self._notify_observers(PlayerMoved(self._destination))
message = "You move to room '{dest}'".format(dest=self._destination)
return ActionResult.player_moved(message, self._destination)
def look(self):
return ActionResult.player_acted("There is a door")
def register(self, observer):
self._subject.register(observer)
def _notify_observers(self, event):
self._subject.notify_observers(event)
Y Dungeon
:
class Dungeon:
def __init__(self, rooms):
self._rooms = rooms
self._current = 'start'
self._subject = Subject()
def go(self, direction):
result = self._current_room().go(Dir(direction))
return result
# Removed code
def register(self, observer):
self._subject.register(observer)
self._rooms.register(observer)
self._rooms.register(self)
def notify(self, event):
if event.name() == "player_moved":
self._current = event.room()
Con estos cambios hay un par de tests que fallan porque dependen de que ActionResult
contenga el resultado adecuado, cosa que ahora no ocurre. La cuestión es ver si los podemos testear de otra forma: esperando que se hayan lanzado los eventos adecuados:
class DungeonAsSubjectTestCase(unittest.TestCase):
def test_supports_current_room_changed_event(self):
fake_observer = FakeObserver()
builder = DungeonBuilder()
builder.add('start')
builder.add('other')
builder.connect('start', Dir.N, 'other')
dungeon = builder.build()
dungeon.register(fake_observer)
dungeon.go(Dir.N)
self.assertTrue(fake_observer.is_aware_of("player_moved"))
def test_supports_player_exited_event(self):
fake_observer = FakeObserver()
builder = DungeonBuilder()
builder.add('start')
builder.set('start', Dir.N, Exit())
dungeon = builder.build()
dungeon.register(fake_observer)
dungeon.go(Dir.N)
self.assertTrue(fake_observer.is_aware_of("player_exited"))
Nos queda revisar un pequeño problema. Se trata de evitar que un mismo observer se suscriba dos veces al mismo subject, como podría ocurrir fácilmente con Dungeon. Puede hacerse así:
class Subject:
def __init__(self):
self._observers = []
def register(self, observer):
if observer in self._observers:
return
self._observers.append(observer)
def notify_observers(self, event):
for observer in self._observers:
observer.notify(event)
Con todos estos cambios, hay varias situaciones en las que ActionResult
ya no es muy útil. Por ejemplo, podemos trasladar la creación de algunos mensajes directamente a Printer
:
class Printer:
def __init__(self, show_output):
self.show_output = show_output
self._command = ""
self._description = ""
self._energy = ""
self._title = ""
def notify(self, event):
if event.name() == "player_energy_changed":
self._energy = str(event.energy().value())
elif event.name() == "player_got_description":
self._description = event.description()
elif event.name() == "player_moved":
self._title = event.room()
self._description = "You moved to room '{dest}'".format(dest=event.room())
elif event.name() == "player_sent_command":
self._command = "{} {}".format(event.command(), event.argument())
# Removed code
De todos modos, ActionResult
sigue siendo difícil de extraer, aunque ya solo le quedan dos usos: mover descripciones de lo que la jugadora puede ver y el coste de los comandos.
Esto último me interesa porque ya es hora de volver a iterar en el juego con un tema relacionado.
Recuperar energía
Que se pierda energía a medida que se avanza en el juego actúa como motivación para intentar salir cuanto antes de la mazmorra. Hace más interesante el juego por cuanto introduce un riego de salir a tiempo. Sin embargo, a medida que crezca la complejidad de la mazmorra y se introduzcan desafíos y puzzles, hará falta una forma de recuperar energía.
Para ello, en algunas habitaciones podría haber comida, de tal forma que la jugadora pueda cogerla y usarla para recuperarse.
Esto tiene varias implicaciones:
- Tiene que haber un objeto que represente la comida (Food) y que aporte cierta cantidad de energía.
- Hay un límite a la cantidad máxima de energía que puede tener la jugadora.
- Tiene que haber una acción que permita a la jugadora coger la comida y comerla (collect food, eat food).
- La jugadora podría recolectar la comida, pero comerla en otro momento. Esto implicaría tener algún tipo de almacenaje que lleva consigo (Bag) que podría tener una capacidad limitada.
Llenar de cosas la mazmorra
Todo esto presenta algunos desafíos.
El primero es que tenemos que poder añadir objetos a una habitación y que la jugadora pueda verlos cuando mira alrededor.
Veamos el test de Room
en su situación actual:
class TestRoom(TestCase):
def setUp(self):
walls = Walls()
walls.set(Dir.N, Exit())
self.room = Room(walls)
def test_wall_in_all_directions(self):
result = self.room.go(Dir.N)
self.assertEqual("Congrats. You're out", result.get("message"))
result = self.room.go(Dir.E)
self.assertEqual('You hit a wall', result.get("message"))
result = self.room.go(Dir.S)
self.assertEqual('You hit a wall', result.get("message"))
result = self.room.go(Dir.W)
self.assertEqual('You hit a wall', result.get("message"))
def test_can_provide_description(self):
description = """North: There is a door
East: There is a wall
South: There is a wall
West: There is a wall
That's all
"""
result = self.room.look('around')
self.assertEqual(description, result.get("message"))
Todavía dependemos de ActionResult
, que está acoplando todo con todo. En el método look
tiene sentido que devuelva un objeto con una descripción de lo que se ve. Pero en el método go
, no está tan claro que sea una buena idea. Quizá Wall
, pueda lanzar un evento “player_hit_wall” en su lugar.
En todo caso, ahora estamos interesadas en el método look
y en obtener una descripción que muestre una lista de objetos disponibles en la habitación.
Al comando look
le podemos pasar un argumento. Podríamos usar esto para limitar la descripción. Para empezar, como no hay objetos, nos dice que no ve nada:
def test_can_provide_description_of_objects_in_empty_room(self):
description = "There are no objects"
result = self.room.look('objects')
self.assertEqual(description, result.get("message"))
Suficiente para introducir un mínimo de código.
class Room:
def __init__(self, walls):
self._walls = walls
def go(self, direction):
wall = self._walls.get(direction)
return wall.go()
def look(self, argument):
if argument == "objects":
return ActionResult.player_acted("There are no objects")
response = ""
response += "North: " + self._walls.get(Dir.N).look().get("message") + "\n"
response += "East: " + self._walls.get(Dir.E).look().get("message") + "\n"
response += "South: " + self._walls.get(Dir.S).look().get("message") + "\n"
response += "West: " + self._walls.get(Dir.W).look().get("message") + "\n"
response += "That's all" + "\n"
return ActionResult.player_acted(response)
def register(self, observer):
self._walls.register(observer)
Ahora hay que refactorizar con los tests en verde para tener algo más manejable:
def look(self, argument):
if argument == "objects":
return self._look_objects()
return self._look_around()
def _look_objects(self):
return ActionResult.player_acted("There are no objects")
def _look_around(self):
response = ""
response += "North: " + self._walls.get(Dir.N).look().get("message") + "\n"
response += "East: " + self._walls.get(Dir.E).look().get("message") + "\n"
response += "South: " + self._walls.get(Dir.S).look().get("message") + "\n"
response += "West: " + self._walls.get(Dir.W).look().get("message") + "\n"
response += "That's all" + "\n"
return ActionResult.player_acted(response)
No me emociona el código que tenemos en _look_around
, así que aprovechemos para arreglarlo un poco. Primero lo muevo todo a _walls
.
def _look_around(self):
response = self._walls.look()
return ActionResult.player_acted(response)
El método look
proporciona una descripción de los objetos a los que se llama con él.
En Walls
es posible hacer esto:
def look(self):
response = ""
response += str(Dir.N.value).capitalize() + ": " + self._walls[Dir.N].look().get("message") + "\n"
response += str(Dir.E.value).capitalize() + ": " + self._walls[Dir.E].look().get("message") + "\n"
response += str(Dir.S.value).capitalize() + ": " + self._walls[Dir.S].look().get("message") + "\n"
response += str(Dir.W.value).capitalize() + ": " + self._walls[Dir.W].look().get("message") + "\n"
response += "That's all" + "\n"
return response
Y luego, un bucle así:
def look(self):
response = ""
for dirs in Dir:
response += str(dirs.value).capitalize() + ": " + self._walls[dirs].look().get("message") + "\n"
response += "That's all" + "\n"
return response
Siempre y cuando hagamos un pequeño cambio en el orden de Dir
, que nos podemos permitir:
class Dir(Enum):
N = "north"
E = "east"
S = "south"
W = "west"
Una vez limpio esto, vamos a ver cómo implementar tener objetos en la habitación. Se me ocurre usar un método put
en room al que se le pasa el objeto que queremos añadir. Sabremos que está bien porque lo vemos en la descripción.
def test_can_put_objects_in_a_room(self):
description = """There are:
* Food
* Wood Sword
* Gold Coin
"""
self.room.put(Thing("Food"))
self.room.put(Thing("Wood Sword"))
self.room.put(Thing("Gold Coin"))
result = self.room.look('objects')
self.assertEqual(description, result.get("message"))
Introduzco un objeto Things
para guardar las Thing
y obtengo esto:
class Things:
def __init__(self):
self._things = dict()
def put(self, a_thing):
self._things[a_thing.name()] = a_thing
def look(self):
if len(self._things) > 0:
response = "There are:\n"
for thing in self._things.values():
response += "* {}\n".format(thing.name())
else:
response = "There are no objects"
return response
class Room:
def __init__(self, walls):
self._walls = walls
self._things = Things()
# Removed code
def _look_objects(self):
response = self._things.look()
return ActionResult.player_acted(response)
# Removed code
def put(self, an_object):
self._things.put(an_object)
Con este código ya podemos poner objetos en las celdas de la mazmorra. Ahora necesitamos usarlo en DungeonBuilder
para poder diseñar mazmorras que contengan objetos.
class TestDungeonBuilder(TestCase):
# Removed code
def test_can_put_things_in_rooms(self):
builder = DungeonBuilder()
builder.add('101')
builder.add('start')
builder.connect('start', Dir.N, '101')
builder.put('101', Thing("Sword"))
builder.set('101', Dir.E, Exit())
dungeon = builder.build()
dungeon.go('north')
response = dungeon.look('objects')
self.assertIn("Sword", response.get("message"))
El Builder quedaría así:
class DungeonBuilder:
def __init__(self):
self._names = []
self._walls = dict()
self._things = dict()
def build(self):
rooms = Rooms()
for name in self._names:
walls = Walls()
for direction, wall in self._walls[name].items():
walls = walls.set(direction, wall)
room = Room(walls)
for thing in self._things[name]:
room.put(thing)
rooms = rooms.set(name, room)
return Dungeon(rooms)
def add(self, room_name):
self._names.append(room_name)
self._walls[room_name] = dict()
self._things[room_name] = []
def set(self, room_name, direction, wall):
self._walls[room_name][direction] = wall
def connect(self, origin, direction, target):
self.set(origin, direction, Door(target))
self.set(target, direction.opposite(), Door(origin))
def put(self, room_name, thing):
self._things[room_name].append(thing)
Y además he descubierto que Dungeon debería registrarse como observer de sus habitaciones nada más construirse:
class Dungeon:
def __init__(self, rooms):
self._rooms = rooms
self._current = 'start'
self._subject = Subject()
self._rooms.register(self)
# Removed code
Y con esto resolvemos la parte de poder disponer objetos (Thing
) en las celdas de la mazmorra. De momento, empezaré con algo muy simple, a la espera de descubrir qué comportamientos necesitamos.
Para celebrar este logro, voy a introducir algunos objetos en la mazmorra “game”.
def _build_dungeon(self):
builder = DungeonBuilder()
for cell in range(23):
builder.add(str(cell))
builder.add('start')
builder.add('exit')
builder.connect('0', Dir.S, '5')
builder.connect('1', Dir.E, '2')
builder.connect('2', Dir.E, '3')
builder.connect('2', Dir.S, '7')
builder.connect('3', Dir.E, '4')
builder.connect('4', Dir.S, '9')
builder.connect('5', Dir.S, '10')
builder.connect('6', Dir.S, '11')
builder.connect('6', Dir.E, '7')
builder.connect('8', Dir.E, '9')
builder.connect('8', Dir.S, '13')
builder.connect('9', Dir.S, 'exit')
builder.connect('10', Dir.E, '11')
builder.connect('11', Dir.E, '12')
builder.connect('11', Dir.S, '15')
builder.connect('13', Dir.S, '17')
builder.connect('14', Dir.S, '19')
builder.connect('15', Dir.S, 'start')
builder.connect('16', Dir.E, '17')
builder.connect('17', Dir.E, '18')
builder.connect('19', Dir.E, 'start')
builder.connect('start', Dir.E, '20')
builder.connect('start', Dir.E, '20')
builder.connect('21', Dir.E, '22')
builder.set('exit', Dir.E, Exit())
builder.put('10', Thing("Food"))
builder.put('17', Thing("Sword"))
builder.put('1', Thing("Food"))
return builder.build()
Por ejemplo:
Remaining energy: 84
======================================
What should I do? >look objects
You said: look objects
10
--------------------------------------
There are:
* Food
Remaining energy: 83
======================================
Sería conveniente hacer que look around
permitiese ver no solo las paredes, sino también la lista de objetos que existe en la sala. Para hacer nos conviene retocar un poco los tests, de tal modo que no dependan tanto del resultado exacto que se muestra.
Hacemos un commit con todos estos cambios.
Poder recoger cosas
Ahora que ya nos podemos encontrar objetos en nuestros recorridos por la mazmorra queremos poder recogerlos y coleccionarlos o usarlos.
Aquí habría que distinguir un matiz entre coleccionar objetos y cogerlos:
- Coleccionar se refiere a coger un objeto y guardarlo en una bolsa o mochila para usar en otro momento. Se puede coleccionar un número limitado de objetos. Esto podría representarse, por ejemplo, con el comando
collect thing
. Siempre se refiere a un objeto que está en la mazmorra y que se pondrá en nuestra bolsa. - Coger se refiere a coger y sostener un objeto para poder usarlo. Diremos que este objeto lo tenemos en la mano. Solo se puede utilizar un objeto a la vez. En este caso, el comando sería
get thing
. Este comando puede actuar sobre objetos que están en la mazmorra o que están e nuestra bolsa.
También necesitaremos un comando use thing
. Este comando podría aplicarse a todo objeto sobre el que se pueda hacer get
o bien sobre el objeto que tenemos ahora mismo en la mano. Usar un objeto nos plantea una cuestión: hay objetos que podremos usar varias veces y objetos de un solo uso. Por ejemplo, la comida se puede usar una vez. Se consume y desaparece. Una espada, por ejemplo, podríamos llevarla con nosotras todo el tiempo que nos parezca.
Además, también se requiere una forma de soltar un objeto. De nuevo, tenemos dos posibles significados o contextos:
- Dejar de tener sujeta una cosa para que vuelva a su sitio. Este comando podría ser
drop
y, para simplificar, podría poner el objeto en la bolsa. - Si queremos deshacernos de un objeto definitivamente sería el comando
leave
, que lo deja en la habitación de la mazmorra en la que nos encontremos.
Como se puede ver, se plantean un montón de cosas aquí, por lo que nos conviene seguir dividiendo esta historia hasta reducirla a un incremento más manejable.
Mi propuesta ahora mismo es implementar get
y use
en primer lugar, que es lo que necesito para poder usar objetos que proporcionen energía. Como acabamos de decir, los comandos get
y use
pueden aplicarse sobre los mismos objetos si están disponibles.
Necesitaremos un GetCommand
cuyo argumento nos indicará qué objeto coger y el cual buscará en la mazmorra después de descartar que no lo tenga ya en la mano. Si no hay ninguno que encaje, no tendrá efectos.
En todo caso, necesitamos que Player
tenga una mano que, inicialmente estará vacía y que puede contener un objeto. Este objeto será el destinatario del comando use
en su momento.
Preparemos un escenario de test para desarrollarlo. En este escenario vamos a empezar asegurando que cuando cogemos un objeto este ya no está en la habitación. El test nos requerirá escribir el comando GetCommand y al menos un método en Dungeon
y Room
para extraer el objeto indicado.
class PlayerGettingThingsTestCase(unittest.TestCase):
def test_player_get_object_removes_from_room(self):
player = Player.awake()
dungeon = self.dungeon_with_object()
player.awake_in(dungeon)
get_command = GetCommand("Food")
player.do(get_command)
description = dungeon.look('objects')
self.assertIn("There are no objects", description.get("message"))
def dungeon_with_object(self):
builder = DungeonBuilder()
builder.add('start')
builder.put('start', Thing("Food"))
return builder.build()
Este sería GetCommand
en una primera implementación:
class GetCommand(Command):
def __init__(self, argument):
super().__init__(argument)
def do(self, receiver):
result = receiver.get(self._argument)
result.set('cost', EnergyUnit(1))
return result
def name(self):
return "get"
Dungeon
básicamente tiene que delegar en la habitación actual este comando, así que tenemos que implementar en Room
, algo que recupere el objeto deseado a través de su nombre. Algo así:
class Room:
def __init__(self, walls):
self._walls = walls
self._things = Things()
# Removed code
def get(self, thing_name):
return self._things.get(thing_name)
Pero aquí nos topamos con los condicionantes de estar usando ActionResult
, así que vamos a salir del paso cambiando un poco el comando GetCommand
, de modo que el test avance.
class GetCommand(Command):
def __init__(self, argument):
super().__init__(argument)
def do(self, receiver):
receiver.get(self._argument)
result = ActionResult("Got thing")
result.set('cost', EnergyUnit(1))
return result
def name(self):
return "get"
Lo que no hemos hecho ha sido retirar el objeto de la lista así que el test falla porque espera que la habitación esté vacía de cosas. Lo podemos arreglar modificando Things
:
class Things:
def __init__(self):
self._things = dict()
# Removed code
def get(self, thing_name):
return self._things.pop(thing_name)
Esto ha hecho pasar el test. El problema interesante viene ahora… ¿Cómo hacemos llegar este objeto a Player
? Devolverlo directamente hace que en el método do
tengamos que saber que algunos comandos devuelven cosas que tenemos que guardar y además queremos deshacernos o minimizar el uso de ActionResult
.
La respuesta es… eventos, eventos everywhere.
Y es que los eventos se pueden aplicar a todo lo que ocurre en el juego para permitir que objetos dispares puedan comunicarse. En este caso, cuando la jugadora coja algo de una habitación de la mazmorra, en lugar de pasarlo de un objeto a otro hasta llegar la propia jugadora, lo que haremos es que esta se suscriba al evento player_got_thing
, en el cual vendrá la cosa que ha cogido, de modo que pueda actualizar su estado sin forzar a ningún objeto intermediario a conocer nada de esta interacción.
Para desarrollar esto vamos a introducir un nuevo test y haré que Player
exponga un método para saber qué tiene en la mano. Puede que este método tenga una vida efímera, pero así podremos avanzar en el desarrollo.
def test_player_get_object_and_holds(self):
thing = Thing("Food")
player = Player.awake()
dungeon = self.dungeon_with_object(thing)
player.awake_in(dungeon)
get_command = GetCommand("Food")
player.do(get_command)
self.assertEqual(thing, player.holds())
He aquí el nuevo evento:
class PlayerGotThing:
def __init__(self, thing):
self._thing = thing
def name(self):
return "player_got_thing"
def thing(self):
return self._thing
Y los cambios en Player
para poder manejarlo:
class Player:
def __init__(self, starting_energy):
self._energy = Energy(starting_energy)
self._last_result = ActionResult.player_acted("I'm ready")
self._subject = Subject()
self._receiver = None
self._holds = None
# Removed code
def awake_in(self, dungeon):
dungeon.register(self)
self._receiver = dungeon
def do(self, command):
self._execute_command(command, self._receiver)
self._update_energy()
def holds(self):
return self._holds
# Removed code
def notify(self, event):
if "player_got_thing" == event.name():
self._holds = event.thing()
Y en Dungeon
para comunicarlo.
class Dungeon:
# Removed code
def get(self, thing_name):
thing = self._current_room().get(thing_name)
self._notify_observers(PlayerGotThing(thing))
# Removed code
def register(self, observer):
self._subject.register(observer)
self._rooms.register(observer)
# Removed code
Incluyo una corrección sobre cómo se estaban registrando los observers, ya que Dungeon
se estaba registrando a sí misma.
Finalmente, los test pasan, de modo que la jugadora ya puede coger objetos. Lo siguiente que necesitamos es que pueda usarlos. Pero antes de eso, sería bueno tener una forma de tener feedback del resultado de la acción. Por supuesto, Printer
puede atender al nuevo evento.
class Printer:
def __init__(self, show_output):
self.show_output = show_output
self._command = ""
self._description = ""
self._energy = ""
self._title = ""
def notify(self, event):
if event.name() == "player_energy_changed":
self._energy = str(event.energy().value())
elif event.name() == "player_got_description":
self._description = event.description()
elif event.name() == "player_moved":
self._title = event.room()
self._description = "You moved to room '{dest}'".format(dest=event.room())
elif event.name() == "player_sent_command":
self._command = "{} {}".format(event.command(), event.argument())
elif event.name() == "player_exited":
self._description = "Congrats. You're out"
elif event.name() == "player_got_thing":
self._description = "You've got {}".format(event.thing().name())
Con estos cambios, pruebo el juego, pero descubro un par de malas noticias.
La primera es que he olvidado que tenemos una CommandFactory
que se encarga de obtener el objeto Command
a partir del input
:
class CommandFactory:
@staticmethod
def from_user_input(user_input):
try:
command, argument = user_input.split(" ", 1)
except ValueError:
command = user_input
argument = "around"
if command == "go":
return GoCommand(argument)
if command == "look":
return LookCommand(argument)
if command == "get":
return GetCommand(argument)
return InvalidCommand(user_input)
La segunda es que el juego falla… porque al pedir get Food
el argumento se traduce a food en ConsoleObtainUserCommand
que normaliza todas las entradas a lower case. Por esa razón, el programa no puede encontrar el objeto deseado, puesto que el nombre puede llevar otro caso. Pese a la buena cobertura de tests que tenemos, ninguno de ellos revela este problema.
Precisamente, si revisamos los últimos tests que hemos escrito se puede observar. La factoría siempre creará los comandos con los argumentos en lower case, mientras que en el test lo pasamos capitalized, dado que así es como ponemos el nombre a los objetos. Este nombre actúa como identificador del objeto, así que en realidad diría que el problema lo tenemos en la forma en que lo registramos.
def test_player_get_object_and_holds(self):
thing = Thing("Food")
player = Player.awake()
dungeon = self.dungeon_with_object(thing)
player.awake_in(dungeon)
get_command = GetCommand("Food")
player.do(get_command)
self.assertEqual(thing, player.holds())
Pero ¿cómo mantener la consistencia en todos los posibles usos? Podríamos introducir validaciones en los objetos Command
, pero también tenemos que garantizar que los identificadores bajo los que guardamos los objetos en las colecciones Things
son correctos.
Este problema viene del smell primitive obsession. Estamos usando tipos primitivos y, por tanto, no podemos garantizar que cumplan ciertas reglas no escritas en nuestro código. Por ejemplo, la de que estos identificadores deberían ser siempre lower case.
Una solución fácil de aplicar, antes de intentar atacar la primitive obsession sería hacer que Things
fuerce esta regla. Para poner de manifiesto el problema, podemos hacer un simple cambio en los tests relacionados con coger objetos para hacerlos fallar.
def test_player_get_object_and_holds(self):
thing = Thing("Food")
player = Player.awake()
dungeon = self.dungeon_with_object(thing)
player.awake_in(dungeon)
get_command = GetCommand("food")
player.do(get_command)
self.assertEqual(thing, player.holds())
Y esto no es otra cosa que un ejemplo de mutation testing: introducir pequeños cambios en los tests para ver si fallan y desvelan casos que no estamos contemplando o que hemos implementado de una forma incompleta.
De hecho, el test ahora falla, revelando el problema subyacente. La solución:
class Things:
def __init__(self):
self._things = dict()
def put(self, a_thing):
self._things[a_thing.name().lower()] = a_thing
def look(self):
if len(self._things) > 0:
response = "There are:\n"
for thing in self._things.values():
response += "* {}\n".format(thing.name())
else:
response = "There are no objects\n"
return response
Con esto hemos resuelto el problema. Pero nos damos cuenta de otra cosa, que solamente se observa cuando ejecutamos el juego: si distintos eventos modifican un mismo campo en Printer
se machacan entre sí, prevaleciendo el último.
Se puede ver aquí:
What should I do? >get food
You said: get food
10
--------------------------------------
Got thing
Remaining energy: 83
======================================
What should I do? >
Got thing
es el mensaje que se pasa mediante ActionResult
como respuesta del comando GetCommand
. Además, este mensaje es pasado en algún momento como evento, por lo que acaba “comiéndose” al evento real.
Tenemos dos problemas. En primer lugar, esta redundancia en los eventos que tendremos que arreglar revisando el papel del ActionResult
y lo que hacemos con él. En segundo, como gestionar que distintos mensajes puedan ocurrir en el mismo ciclo de juego para acumularlos en lugar de machacarse mutuamente.
Estos dos problemas los voy a posponer. Antes quiero completar la iteración: una vez que podemos tener comida en la mano, hay que poder comerla.
Usar objetos
Hemos quedado que para usar un objeto tiene que estar en nuestra mano, lo que ocurre tras un comando get
. Para utilizar un objeto emplearemos el nuevo comando use
. El receiver natural de este sería un objeto de tipo Thing
.
Pero Player
tiene a Dungeon
como único receiver, por lo que tendremos que hacer cambios nos permitan tener varios receivers. Nos interesa un patrón cadena de responsabilidad, ya que al recibir el comando que sea, no sabemos exactamente qué objeto será su receiver real.
De hecho, en nuestro juego hay comandos como look
que pueden ser respondidos por todos los objetos, mientras que otros solo pueden ser aplicados por alguno específico. Aparte, todos los objetos serían capaces de pasar los comandos a otros objetos independientemente de que sean capaces de ejecutarlos o no.
Por ejemplo, Dungeon
pasa comandos a la Room
actual, que a su vez los pasa a sus Walls
. En algún momento alguien responde.
Aquí tenemos un buen percal, puesto que cada Command
ejecuta un método específico en el receiver
. Esto funcionaba bien hasta ahora, porque el único receiver
es Dungeon
y delega cuando es necesario. Si bien, esto funciona, tiene limitaciones y problemas. Por ejemplo, Dungeon
tiene que tener definidos métodos para todos los comandos que podría llegar a recibir, aunque realmente no maneja ninguno, sino que se invoca ese comando en la habitación actual.
Una posibilidad sería que cualquier objeto que pueda recibir comandos exponga un método do
o execute
. Este método comprobaría si puede ejecutar o no el comando. Si es que sí, invoca el método correspondiente, si es que no, delega en otro objeto.
Pero, ¿qué podríamos hacer temporalmente para sacar la feature de usar objetos? Recordemos que únicamente tenemos un objeto con el que lidiar y que solo interesa a un posible receiver (Player
), sobre el que tiene un efecto muy definido.
Lo primero, hagamos un test, que ha resultado un pelín largo tras algunas correcciones:
class PlayerUsingFoodTestCase(unittest.TestCase):
def test_using_food_makes_player_increase_energy(self):
thing = Thing("Food")
fake_observer = FakeObserver()
dungeon = self.dungeon_with_object(thing)
player = Player.awake_with_energy(EnergyUnit(50))
player.awake_in(dungeon)
player.register(fake_observer)
player.do(GetCommand("Food"))
player.do(UseCommand("food"))
last_energy_event = fake_observer.last("player_energy_changed")
self.assertEqual(58, last_energy_event.energy().value())
self.assertIsNone(player.holds())
def dungeon_with_object(self, thing):
builder = DungeonBuilder()
builder.add('start')
builder.put('start', thing)
return builder.build()
En pocas palabras, el test comienza definiendo una jugadora que recoge y usa un objeto de comida. Lo que esperamos es que su energía cambie: empieza con 50 se descuenta el gasto de los dos comandos (una unidad cada uno) y se añaden diez unidades de haber consumido la comida. El resultado sería 58.
Además, se espera que este objeto se consuma en su totalidad, por lo que en la mano no quedará nada.
Empezamos definiendo UseCommand
:
class UseCommand(Command):
def __init__(self, argument):
super().__init__(argument)
def name(self):
return "use"
def do(self, receiver):
receiver.use(self._argument)
Ahora ejecutamos el test y vemos que falla porque Dungeon no tiene el método use
. Obviamente no está implementado, pero además de momento solo queremos que este comando lo ejecute Player
, así que vamos a introducir código en este objeto pero solo para ese caso:
class Player:
# Removed code
def _execute_command(self, command, receiver):
if command.name() == "use":
command.do(self)
return
self._last_result = command.do(receiver)
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
self._notify_observers(PlayerGotDescription(self._last_result.get('message')))
Ahora ya falla porque es Player
quien no tiene un método use
. En este método básicamente comprobamos que el objeto que tenemos en la mano es el indicado por el comando. Si no es así, de momento no hacemos nada, más adelante pondremos un evento que señale que no se puede usar el objeto:
def use(self, thing_name):
if self._holds is not None and self._holds.name() != thing_name:
return
El siguiente paso es bastante obvio: Player
tiene que usar el objeto. Lo que no sabemos es la forma específica en que esto ha de ocurrir y que debería definir el propio objeto. Para eso, podemos usar un double dispatch:
def use(self, thing_name):
if self._holds is not None and self._holds.name() != thing_name:
return
self._holds.apply_on(self)
Es decir, un objeto se pasa a otro objeto, para que este último lo utilice según sea adecuado.
class Thing:
def __init__(self, name):
self._name = name
def name(self):
return self._name
def apply_on(self, user):
user.increase_energy(EnergyUnit(10))
Y supongo que estás pensando que no todos los objetos Thing
van a provocar ese efecto allá donde se apliquen. Esto es una consideración importante porque querremos tener objetos que causen distintos efectos cuando se usan, pero no la vamos a atacar ahora. De momento, queremos que funcione en este único caso, lo que sentará las bases del desarrollo futuro.
Así que, volviendo a Player
:
class Player:
# Removed code
def use(self, thing_name):
if self._holds is not None and self._holds.name() != thing_name:
return
self._holds.apply_on(self)
self._holds = None
def increase_energy(self, delta_energy):
self._energy.increase(delta_energy)
Si vamos ejecutando el test nos daremos cuenta de que la energía no ha cambiado y esto tiene una explicación sencilla. Es debido al problema del caso del identificador de los objetos, que no hemos tenido en cuenta. Así que, de momento, voy a hacer un rápido parche para que el test pase:
def use(self, thing_name):
if self._holds is not None and self._holds.name().lower() != thing_name.lower():
return
self._holds.apply_on(self)
self._holds = None
def increase_energy(self, delta_energy):
self._energy.increase(delta_energy)
¡Buen provecho!
No olvidemos registrar el nuevo comando en la factoría:
class CommandFactory:
@staticmethod
def from_user_input(user_input):
try:
command, argument = user_input.split(" ", 1)
except ValueError:
command = user_input
argument = "around"
if command == "go":
return GoCommand(argument)
if command == "look":
return LookCommand(argument)
if command == "get":
return GetCommand(argument)
if command == "use":
return UseCommand(argument)
return InvalidCommand(user_input)
Con este código, la jugadora ya se come la comida, lo que le proporciona energía. Este objeto no se puede volver a utilizar porque se consume todo, así que desaparece.
¿Limitaciones? Muchas. El objeto Thing
tiene ahora un comportamiento muy específico, que no es válido para otros tipos de objetos que podamos disponer en la mazmorra. Imagínate una espada, una llave o una piedra. Lo interesante de esta primera implementación es que ya va revelando los rasgos que tendremos que tener en cuenta en esta familia de objetos:
- Se usan sobre otro objeto. Los objetos de comida se aplican sobre
Player
, pero una espada podría emplearse sobre un enemigo, mientras que una llave podría abrir una puerta. - Pueden consumirse totalmente o pueden usarse repetidas veces.
Lo anterior me sugiere que tendremos objetos Thing
con especializaciones. Ya volveremos a eso.
En cualquier caso, es momento de refactorizar la solución y completar algunos aspectos, como reaccionar adecuadamente cuando no se tiene un objeto en la mano sobre el que actuar, o cuando el objeto no se puede aplicar sobre Player
. Por lo menos tenemos que generar un feedback para que la jugadora sepa qué ha pasado.
Y puesto que el juego funciona, pese a que todavía tiene algunos inconvenientes, consolidamos los cambios.
Estos inconvenientes de los que hablamos están relacionados, sobre todo, con la falta de feedback, o al menos que este no sea lo bastante claro:
What should I do? >get food
You said: get food
10
--------------------------------------
Got thing
Remaining energy: 83
======================================
What should I do? >use food
You said: get food
10
--------------------------------------
Got thing
Remaining energy: 92
======================================
Aquí podemos ver que tras introducir el comando use food
se indica que hemos dicho get food
que es incorrecto. Además, aparece la frase genérica Got thing
en ambos casos, cuando debería indicarnos qué ha pasado (Got food
y Used food
). Por otro lado, aunque se aprecia el cambio en el nivel de energía, no se explica el efecto.
Todo esto tiene su raíz en la interferencia entre ActionResult
comunicando los efectos de las acciones y los eventos y Printer
no sabiendo manejarlo.
En fin. Hay muchos temas en el aire y me gustaría abordarlos uno a uno, pero también necesito dedicar un tiempo a limpiar cosas y ordenar los últimos cambios en el código. Poco a poco, se va generando un montón de basurilla que empieza a complicar las cosas otra vez.
Introduciendo solidez en el uso de objetos
Para empezar, quiero terminar de implementar bien UseCommand
en el sentido de gestionar los diversos sad paths. Es decir, qué pasa si realmente no puedo usar el objeto o no tengo objeto que usar.
Añado un test para este último caso en el que Player no ha cogido ningún objeto antes. Podría lanzar un evento genérico action_not_completed
que contenga la razón. Además de eso, verificamos que no hay otros cambios significativos.
def test_trying_to_use_an_object_but_holding_none(self):
fake_observer = FakeObserver()
dungeon = self.dungeon_with_object(Thing("Food"))
player = Player.awake_with_energy(EnergyUnit(50))
player.awake_in(dungeon)
player.register(fake_observer)
player.do(UseCommand("food"))
self.assertTrue(fake_observer.is_aware_of("action_not_completed"))
last_energy_event = fake_observer.last("player_energy_changed")
self.assertEqual(49, last_energy_event.energy().value())
self.assertIsNone(player.holds())
Como era de esperar, el test se rompe porque al no tener objeto en la mano no podemos enviarle ningún mensaje. Tenemos que prevenir esto:
class Player:
# Removed code
def use(self, thing_name):
if self._holds is None:
return
if self._holds is not None and self._holds.name().lower() != thing_name.lower():
return
self._holds.apply_on(self)
self._holds = None
El siguiente problema es que Player
espera un ActionResult
para obtener el coste de ejecutar un comando. UseCommand
no devuelve nada y no quiero que devuelva nada precisamente porque quiero sacar a ActionResult
de ahí.
Lo que me planteo es que esto se pueda hacer de otra manera, de forma que los Command
puedan informar a Player
del coste, al menos de momento. Pero intentaré hacer convivir las dos ideas mientras hago estos cambios.
Primero añado el método público Cost
a Command
.
class Command:
def __init__(self, argument):
self._argument = argument
def do(self, receiver):
pass
def name(self):
return None
def cost(self):
return EnergyUnit(1)
def argument(self):
if hasattr(self, "_argument"):
return self._argument
return ""
También añado el evento:
class ActionNotCompleted:
def __init__(self, reason):
self._reason = reason
def name(self):
return "action_not_completed"
def reason(self):
return self._reason
Y completo Player
con los siguientes cambios:
class Player:
def __init__(self, starting_energy):
self._energy = Energy(starting_energy)
self._last_result = ActionResult.player_acted("I'm ready")
self._subject = Subject()
self._receiver = None
self._holds = None
self._last_command = None
def _execute_command(self, command, receiver):
self._last_result = None
if command.name() == "use":
command.do(self)
self._last_command = command
return
self._last_result = command.do(receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
self._notify_observers(PlayerGotDescription(self._last_result.get('message')))
def use(self, thing_name):
if self._holds is None:
self._notify_observers(ActionNotCompleted("You need an object to use it."))
return
if self._holds is not None and self._holds.name().lower() != thing_name.lower():
return
self._holds.apply_on(self)
self._holds = None
# Removed code
def _last_action_cost(self):
if self._last_result is not None:
return self._last_result.get("cost")
if self._last_command is not None:
if hasattr(self._last_command, "cost"):
return self._last_command.cost()
# Removed code
Y con esto pasa el test. Tenemos que hacer que Printer
sea capaz de manejar este evento porque si jugamos, aunque no se rompe nada, tampoco se nos explica nada. Es un poco WTF:
What should I do? >use food
You said: go north
15
--------------------------------------
Remaining energy: 94
======================================
De hecho, es que no sabemos si tenemos algo en la mano o no, y sería bueno que la pantalla nos diese ese feedback.
El caso es que, pensándolo, me doy cuenta de que hay situaciones en las que un campo tiene que reiniciarse y otras en las que el campo tiene que acumular información. Eso es algo que ahora no se contempla. Pero primero, el evento:
class Printer:
def __init__(self, show_output):
self.show_output = show_output
self._command = ""
self._description = ""
self._energy = ""
self._title = ""
def notify(self, event):
if event.name() == "player_energy_changed":
self._energy = str(event.energy().value())
# Removed code
elif event.name() == "action_not_completed":
self._description = "Action was not finished because {}".format(event.reason())
Esto está mejor, no perfecto, pero mucho mejor.
What should I do? >use food
You said:
--------------------------------------
Action was not finished because You need an object to use it.
Remaining energy: 99
======================================
Por cierto, necesitamos introducir un comando para terminar el juego y no tener que recurrir a Ctrl-C
. Otra cosa en la que habrá que pensar más adelante.
Finalmente, otro tema. ¿Qué pasa si un objeto no se puede usar? También debería fallar de manera gracefully, o sea, de buenas maneras, sin romper nada. Esto ocurre cuando el objeto sobre el que se usa Thing
no cumple con la interfaz esperada.
Sin embargo, ahora mismo creo es un poco prematuro preocuparse de este asunto. En primer lugar, no tenemos nuevas Thing
que se puedan usar de otra forma. Además, cuando usemos otros objetos puede que no los apliquemos a Player
, sino que lo intentemos aplicar sobre otras entidades del juego. Quiero decir, que tenemos que saber que Thing
no se ha podido usar en ningún otro posible objeto antes de anunciar que no ha tenido efectos. Y podríamos tener situaciones en las que incluso así el objeto no pudiera reutilizarse. Hay muchas cosas que no sabemos todavía.
Próximos pasos
Como he mencionado en esta entrega, quedan un montón de temas abiertos y necesito tomar un tiempo para arreglar cosas antes de que sea demasiado complicado abordarlas al añadir prestaciones nuevas al juego. Por eso, espero que la próxima entrega sea una nueva sesión de revisión y limpieza.
Mis principales objetivos serán acabar de una vez por todas con ActionResult
y arreglar las limitaciones de Printer
, de modo que la información para la jugadora sea más correcta y completa.
De momento, hemos llegado a este punto en el código como puedes ver en el commit.