El coste de cost
Lo primero que voy a abordar es el tema de Command.cost()
. Ahora que he introducido el concepto en UseCommand
me gustaría generalizarlo y aplicarlo en todos, eliminando así uno de los últimos usos de ActionResult
.
En la clase base Command
ya se establece el coste por defecto de cada uno, por lo que solo tendría que sobreescribirlo en aquellos que tengan un coste especial, como GoCommand
.
class GoCommand(Command):
def __init__(self, argument):
super().__init__(argument)
def do(self, receiver):
result = receiver.go(self._argument)
result.set('cost', self.cost())
return result
def cost(self):
return EnergyUnit(5)
def name(self):
return "go"
Lo siguiente, sería asegurarnos de que calcularemos el coste de la última acción de Player
usando este método y no ActionResult
.
def _last_action_cost(self):
if self._last_command is not None:
if hasattr(self._last_command, "cost"):
return self._last_command.cost()
Hay que hacer un cambio en un comando para test:
class TestCommand(Command):
def __init__(self, energy_consumption):
self._energy_consumption = energy_consumption
def do(self, receiver):
result = ActionResult.player_acted("You did something")
result.set('cost', self.cost())
result.set('command', "test command")
return result
def cost(self):
return self._energy_consumption
Ahora veo si puedo quitar sin problema las líneas de Command que rellenan la clave cost
de ActionResult
.
class GoCommand(Command):
def __init__(self, argument):
super().__init__(argument)
def do(self, receiver):
return receiver.go(self._argument)
def cost(self):
return EnergyUnit(5)
def name(self):
return "go"
Así es. He puesto el ejemplo de GoCommand
, pero todos los demás igual.
Por otra parte, tenemos este test de ActionResult
, que no me parece que esté aportando gran cosa. En realidad intenta verificar que los distintos constructores de ActionResult
funcionan como deben, pero nos los vamos a cargar, así que a la basura con el test.
class TestActionResult(TestCase):
def test_generic_action_result(self):
result = ActionResult.player_acted("message")
self.assertFalse(result.get("exited"))
self.assertIsNone(result._bag.get("destination"))
def test_moving_action_result(self):
result = ActionResult.player_moved("message", 'room')
self.assertFalse(result.get("exited"))
self.assertEqual('room', result._bag.get("destination"))
def test_exit_action_result(self):
result = ActionResult.player_exited("message")
self.assertTrue(result.get("exited"))
self.assertIsNone(result._bag.get("destination"))
def test_game_started_result(self):
result = ActionResult.game_started()
self.assertFalse(result._bag.get("exited"))
self.assertIsNone(result._bag.get("destination"))
def test_action_result_with_cost(self):
result = ActionResult.player_acted("Action")
result.set('cost', EnergyUnit(3))
self.assertEqual(EnergyUnit(3), result._bag.get("cost"))
Al quitar el test, podemos ver qué código podemos eliminar de ActionResult
porque ya ha dejado de usarse. No conseguimos mucho con eso, pero todavía tenemos varios usos.
En cualquier caso, ahora mismo el único uso de ActionResult
tiene que ver con transmitir los “cambios en la escena”, cosa que se hace en Player
, que emite un evento cuando ha terminado la acción, como se puede ver en la última línea del bloque. Más sobre eso dentro de un momento.
def _execute_command(self, command, receiver):
self._last_result = None
if command.name() == "use":
command.do(self)
self._last_command = command
return
self._last_result = command.do(receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
self._notify_observers(PlayerGotDescription(self._last_result.get('message')))
Además, al ver este código nos damos cuenta de por qué el feedback sobre el comando UseCommand es equivocado. Simplemente no se notifica. Hay que cambiar un poquito ese código:
def _execute_command(self, command, receiver):
self._last_result = None
if command.name() == "use":
command.do(self)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
return
self._last_result = command.do(receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
self._notify_observers(PlayerGotDescription(self._last_result.get('message')))
Todavía queda un poco sucio, pero con eso podemos guardar los cambios realizados.
Una cuestión que puedes considerar es por qué usar un método de Command
para comunicar a Player
el coste de cada comando en lugar de un evento. El motivo es que Command
y Player
tienen una relación que permite eso. Command
se pasa a Player
, que puedo interrogarlo para obtener esa información. Podríamos hacerlo de otra manera, para evitar exponer el coste. Pero quizá más adelante.
Cambiando ActionResult
por eventos
Mi siguiente objetivo es cargarme ActionResult
, que no va a ser cosa sencilla. Para eso tengo empezar a asegurar que todas las acciones se comunican de algún modo.
Mi primer punto de atención es Player. El objetivo es eliminar la emisión del evento que informa del resultado de las acciones que ahora usa el contenido de ActionResult
. Tengo que identificar si alguna acción no genera el feedback adecuado para la jugadora.
Otra razón para empezar por aquí es que Player
es el último receptor de ActionResult
. Si dejo de usarlo aquí, puedo dejar de devolverlo en los Command
y así progresivamente hasta suprimirlo del todo y sustituirlo por un sistema mejor.
El problema en este tema es que ando un poco a ciegas. No tengo garantía de tener tests suficientes para el refactor y no sé muy bien como hacerlos.
En este caso podríamos usar, por ejemplo, una técnica Mikado, de modo que las cosas rompan por algún lado y pueda actuar en consecuencia. Por ejemplo, añadiendo tests que reproduzcan esos problemas.
Así que comento esta línea:
def _execute_command(self, command, receiver):
self._last_result = None
if command.name() == "use":
command.do(self)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
return
self._last_result = command.do(receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
# self._notify_observers(PlayerGotDescription(self._last_result.get('message')))
Y obtengo un test que falla… test que especificamente verifica ese comportamiento. Así que tengo que eliminar este test o modificarlo.
def test_notifies_player_got_description_event(self):
fake_observer = FakeObserver()
player = Player.awake_with_energy(EnergyUnit(100))
player.register(fake_observer)
player.do(TestCommand(EnergyUnit(50)))
self.assertTrue(fake_observer.is_aware_of("player_got_description"))
A favor de eliminarlo está que específicamente espera que Player
lance este evento, cosa que no quiero que ocurra. La cuestión es que este evento me interesa como resultado de los comandos Look
, por lo que debería testearlo de alguna otra forma.
De momento me inclino por quitarlo, pero tengo que tener esto en cuenta.
Al quitarlo, el resto de tests siguen pasando. Ahora comprobaré manualmente qué problemas puedo tener. En principio esto no afectará a que el juego pueda jugarse, pero las pantallas de juego podrían no tener toda la información necesaria.
La primera en la frente:
What should I do? >look
You said: look around
--------------------------------------
Remaining energy: 99
======================================
Es exactamente lo que había pensado. El comando Look
ahora no comunica lo que se ve. El caso es que casi todos los objetos usan Look
. Se me ocurre que Dungeon podría encargarse de lanzar el evento, ya que implementa look
y recibe la información de sus rooms.
Esto restaura la funcionalidad de Look
en el juego. Y nada más parece afectado. De hecho, parece estar funcionado bien.
def look(self, focus):
result = self._current_room().look(focus)
description = result.get("message")
self._notify_observers(PlayerGotDescription(description))
return result
Lo siguiente será revisar que Player
no necesita usar más el resultado de los comandos. De hecho, parece que no se usa para nada más, así que voy a quitarlo y ver qué rompe.
def _execute_command(self, command, receiver):
if command.name() == "use":
command.do(self)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
return
command.do(receiver)
self._last_command = command
self._notify_observers(PlayerSentCommand(command.name(), command.argument()))
No falla ningún test y el juego sigue funcionando. Puedo quitar el return del resultado de los Command
, pero hay un caso que produce un resultado inesperado. Si introduzco un comando inválido, el sistema no lo detecta. Se puede jugar, pero no nos informa bien:
What should I do? >gter as
You said: None gter as
--------------------------------------
Remaining energy: 99
======================================
Este es el próximo desafío, una vez que limpie el resto de comandos. Es decir, tengo que hacer que cuando el comando no es válido se vea un feedback correcto en la pantalla.
Antes de eso, voy a avanzar un poco con ActionResult. Ahora que los Command
no lo devuelven, tampoco es necesario que Dungeon lo haga. Si quito este return
lo que me encuentro es que fallan algunos tests porque dependen de esa respuesta. Así que tenemos que cambiarlos primero. Hay que plantearlos para observar otros efectos. Por ejemplo, que se tiran los eventos adecuados.
Por ejemplo, en Dungeon
, quitar el return
hace que fallen dos tests:
def look(self, focus):
result = self._current_room().look(focus)
description = result.get("message")
self._notify_observers(PlayerGotDescription(description))
Uno es este:
class TestDungeonBuilder(TestCase):
# Removed code
def test_can_put_things_in_rooms(self):
builder = DungeonBuilder()
builder.add('101')
builder.add('start')
builder.connect('start', Dir.N, '101')
builder.put('101', Thing("Sword"))
builder.set('101', Dir.E, Exit())
dungeon = builder.build()
dungeon.go('north')
response = dungeon.look('objects')
self.assertIn("Sword", response.get("message"))
Que debería poder reemplazarse por un test que compruebe el evento:
def test_can_put_things_in_rooms(self):
fake_observer = FakeObserver()
builder = DungeonBuilder()
builder.add('101')
builder.add('start')
builder.connect('start', Dir.N, '101')
builder.put('101', Thing("Sword"))
builder.set('101', Dir.E, Exit())
dungeon = builder.build()
dungeon.register(fake_observer)
dungeon.go('north')
dungeon.look('objects')
last_event = fake_observer.last("player_got_description")
self.assertIn("Sword", last_event.description())
El otro test que falla es este:
class PlayerGettingThingsTestCase(unittest.TestCase):
def test_player_get_object_removes_from_room(self):
player = Player.awake()
dungeon = self.dungeon_with_object(Thing("Food"))
player.awake_in(dungeon)
get_command = GetCommand("food")
player.do(get_command)
description = dungeon.look('objects')
self.assertIn("There are no objects", description.get("message"))
# Removed code
Y debería poder cambiarse por este:
class PlayerGettingThingsTestCase(unittest.TestCase):
def test_player_get_object_removes_from_room(self):
fake_observer = FakeObserver()
player = Player.awake()
dungeon = self.dungeon_with_object(Thing("Food"))
dungeon.register(fake_observer)
player.awake_in(dungeon)
get_command = GetCommand("food")
player.do(get_command)
dungeon.look('objects')
last_event = fake_observer.last("player_got_description")
self.assertIn("There are no objects", last_event.description())
# Removed code
Ahora vamos con el comando Dungeon.go()
. En este caso fallan más tests.
class Dungeon:
def __init__(self, rooms):
self._rooms = rooms
self._current = 'start'
self._subject = Subject()
self._rooms.register(self)
def go(self, direction):
self._current_room().go(Dir(direction))
Por ejemplo, este:
class TestDungeonBuilder(TestCase):
def test_can_add_room_with_exit_to_North(self):
builder = DungeonBuilder()
builder.add('start')
builder.set('start', Dir.N, Exit())
dungeon = builder.build()
result = dungeon.go('north')
self.assertTrue(result.get("exited"))
Estamos en la misma situación. Este test puede cambiarse para chequear el evento:
class TestDungeonBuilder(TestCase):
def test_can_add_room_with_exit_to_North(self):
fake_observer = FakeObserver()
builder = DungeonBuilder()
builder.add('start')
builder.set('start', Dir.N, Exit())
dungeon = builder.build()
dungeon.register(fake_observer)
dungeon.go('north')
self.assertTrue(fake_observer.is_aware_of("player_exited"))
El segundo test que falla podría cambiarse también:
def test_can_add_room_with_several_doors(self):
builder = DungeonBuilder()
builder.add('start')
builder.set('start', Dir.N, Exit())
builder.set('start', Dir.S, Exit())
dungeon = builder.build()
result = dungeon.go('north')
self.assertTrue(result.get("exited"))
result = dungeon.go('south')
self.assertTrue(result.get("exited"))
Aunque es un poquito extraño:
def test_can_add_room_with_several_doors(self):
fake_observer = FakeObserver()
builder = DungeonBuilder()
builder.add('start')
builder.set('start', Dir.N, Exit())
builder.set('start', Dir.S, Exit())
dungeon = builder.build()
dungeon.register(fake_observer)
dungeon.go('north')
self.assertTrue(fake_observer.is_aware_of("player_exited"))
dungeon.go('south')
self.assertTrue(fake_observer.is_aware_of("player_exited"))
El último test en fallar necesita el mismo tratamiento:
def test_can_add_several_rooms(self):
builder = DungeonBuilder()
builder.add('101')
builder.add('start')
builder.set('101', Dir.S, Exit())
builder.set('start', Dir.N, Exit())
dungeon = builder.build()
result = dungeon.go('north')
self.assertTrue(result.get("exited"))
Usando las notificaciones igualmente.
def test_can_add_several_rooms(self):
fake_observer = FakeObserver()
builder = DungeonBuilder()
builder.add('101')
builder.add('start')
builder.set('101', Dir.S, Exit())
builder.set('start', Dir.N, Exit())
dungeon = builder.build()
dungeon.register(fake_observer)
dungeon.go('north')
self.assertTrue(fake_observer.is_aware_of("player_exited"))
Para terminar esta acción de limpieza quedarían algunos pasos más. Es necesario probarlo manualmente, para asegurar que la jugadora no pierde información. Esta prueba no muestra ningún problema aparente, por lo que seguimos adelante.
Lo siguiente que necesito revisar es seguir eliminando retornos innecesarios.
Dungeon.go()
ya no devuelve nada ni usa el retorno que viene de sus miembros, así que vamos a empezar por ahí.
class Room:
# Removed code
def go(self, direction):
wall = self._walls.get(direction)
return wall.go()
# Removed code
Y observamos que falla un test. Este test igualmente debería basarse en eventos. El problema es que no tenemos eventos para todo:
class TestRoom(TestCase):
# Removed code
def test_wall_in_all_directions(self):
result = self.room.go(Dir.N)
self.assertEqual("Congrats. You're out", result.get("message"))
result = self.room.go(Dir.E)
self.assertEqual('You hit a wall', result.get("message"))
result = self.room.go(Dir.S)
self.assertEqual('You hit a wall', result.get("message"))
result = self.room.go(Dir.W)
self.assertEqual('You hit a wall', result.get("message"))
Así que tendremos que introducir nuevos:
def test_wall_in_all_directions(self):
self.room.go(Dir.N)
self.assertTrue(self.fake_observer.is_aware_of("player_exited"))
self.room.go(Dir.E)
self.assertTrue(self.fake_observer.is_aware_of("player_hit_wall"))
self.room.go(Dir.S)
self.assertTrue(self.fake_observer.is_aware_of("player_hit_wall"))
self.room.go(Dir.W)
self.assertTrue(self.fake_observer.is_aware_of("player_hit_wall"))
El emisor de este evento será Wall
, al igual que Door
y Exit
emiten los suyos.
class Wall:
def __init__(self):
self._subject = Subject()
def go(self):
self._notify_observers(PlayerHitWall())
return ActionResult.player_acted("You hit a wall")
def look(self):
return ActionResult.player_acted("There is a wall")
def register(self, observer):
self._subject.register(observer)
def _notify_observers(self, event):
self._subject.notify_observers(event)
Y este es el evento:
class PlayerHitWall:
def __init__(self):
pass
def name(self):
return "player_hit_wall"
Evento que tenemos que esuchar en Printer
:
class Printer:
def __init__(self, show_output):
self.show_output = show_output
self._command = ""
self._description = ""
self._energy = ""
self._title = ""
def notify(self, event):
if event.name() == "player_energy_changed":
self._energy = str(event.energy().value())
# Removed code
elif event.name() == "player_hit_wall":
self._description = "You hit a wall. There is no door."
A continuación, sigo eliminando retornos del método go en Wall, Door y Exit. Me falla este test:
class TestWalls(TestCase):
def setUp(self):
self.walls = Walls()
# Removed code
def test_door_go_moves_player_to_another_room(self):
door = Door('destination')
result = door.go()
self.assertEqual('destination', result._bag.get("destination"))
De nuevo es un test que debería comprobar que se lanza el evento adecuado:
def test_door_go_moves_player_to_another_room(self):
fake_observer = FakeObserver()
door = Door('destination')
door.register(fake_observer)
door.go()
event = fake_observer.last("player_moved")
self.assertEqual('destination', event.room())
Con todos estos arreglos, los tests pasan y ya no queda ningún método go
devolviendo nada, si no que sus efectos se publican como eventos.
Y eso me permite quitar más cosas de ActionResult
que ahora contiene solo la clave “message”.
class ActionResult:
@classmethod
def player_acted(cls, message):
return cls(message, None, False)
def __init__(self, message, destination=None, exited=False):
self._bag = ResultBag()
self._bag.set("message", message)
def get(self, key):
return self._bag.get(key)
def set(self, key, data):
self._bag.set(key, data)
Ahora mismo todas las descripciones le van “cayendo” a Dungeon
, que las envía como evento. Quiero evitar eso, de modo que cada objeto se encargue de notificar la descripción que le corresponda. Así, Dungeon
no tendría una descripción propia, pero Room
sí, pues responde a look around
y look objects
. Si fuera necesario, aunque no está implementado todavía, un comando como look food
o look sword
apuntaría los objetos Thing
correspondientes.
Resumiento, en Dungeon, quiero pasar de esto:
def look(self, focus):
result = self._current_room().look(focus)
description = result.get("message")
self._notify_observers(PlayerGotDescription(description))
A esto:
def look(self, focus):
self._current_room().look(focus)
Por supuesto, fallan algunos tests hago este cambio porque ya no se lanza el evento necesario. Por ejemplo:
class PlayerGettingThingsTestCase(unittest.TestCase):
def test_player_get_object_removes_from_room(self):
fake_observer = FakeObserver()
player = Player.awake()
dungeon = self.dungeon_with_object(Thing("Food"))
dungeon.register(fake_observer)
player.awake_in(dungeon)
get_command = GetCommand("food")
player.do(get_command)
dungeon.look('objects')
last_event = fake_observer.last("player_got_description")
self.assertIn("There are no objects", last_event.description())
Room
sería la responsable de lanzar este evento. Y estos son los cambios que necesita, los cuales rompen varios tests, aunque son fáciles de arreglar.
def go(self, direction):
wall = self._walls.get(direction)
wall.go()
def look(self, argument):
if argument == "objects":
return self._look_objects()
return self._look_around()
def _look_objects(self):
response = self._things.look()
self._notify_observers(PlayerGotDescription(response))
def _look_around(self):
response = self._things.look()
response += self._walls.look()
self._notify_observers(PlayerGotDescription(response))
Los siguientes pasos que necesito resultan un poco más complicados, ya que quiero quitar ActionResult
de sus últimas usuarias: la familia de objetos Wall
.
Para facilitar el trabajo, creo que primero debería limpiar un poco el código ya que forman una jerarquía en la que pueden compartir funcionalidad a través de su clase base Wall
. Aquí tenemos el ejemplo de Wall
y Exit
:
class Wall:
def __init__(self):
self._subject = Subject()
def go(self):
self._notify_observers(PlayerHitWall())
def look(self):
return ActionResult.player_acted("There is a wall")
def register(self, observer):
self._subject.register(observer)
def _notify_observers(self, event):
self._subject.notify_observers(event)
class Exit(Wall):
def __init__(self):
super().__init__()
def go(self):
self._notify_observers(PlayerExited())
def look(self):
return ActionResult.player_acted("There is a door")
Para empezar, voy a eliminar la necesidad de ActionResult
añadiendo un método alternativo. Aquí el ejemplo:
class Exit(Wall):
def __init__(self):
super().__init__()
def go(self):
self._notify_observers(PlayerExited())
def look(self):
return ActionResult.player_acted(self.description())
def description(self):
return "There is a door"
Este método es el que usaré en Walls
, para construir la respuesta que se devuelve en look
.
def look(self):
response = ""
for dirs in Dir:
response += "{0}: {1}\n".format(str(dirs.value).capitalize(), self._walls[dirs].description())
response += "That's all" + "\n"
return response
Ahora debería poder reemplazar el cuerpo de los métodos look
por la publicación de un evento. Además, me basta con tenerlo en la clase base, ya que ahora sería un template method que llama a self.description()
class Wall:
def __init__(self):
self._subject = Subject()
def go(self):
self._notify_observers(PlayerHitWall())
def look(self):
self._notify_observers(PlayerGotDescription(self.description()))
def description(self):
return "There is a wall"
def register(self, observer):
self._subject.register(observer)
def _notify_observers(self, event):
self._subject.notify_observers(event)
Y, como resultado, ya no tengo más usos de ActionResult
. Así que puedo eliminarlo por fin.
Quitando más cosas feas
¿Qué es código feo? El objetivo del diseño de software no es hacer código bonito, sino código bien organizado, fácil de comprender, de mantener y de extender. Eventualmente, el código bien diseñado suele mostrar cierta estética. Es difícil de definir pues se trata de una sensación.
Y del mismo modo, se podría percibir cierta fealdad en el código mal diseñado. Es un concepto similar al de smell: algo que puede funcionar correctamente, pero que podría indica que algo no está bien hecho.
Fealdad y falta de cohesión
En el código de Dungeon podemos apreciar algunas áreas de código que se aprecian como feas. Vamos a ver qué nos dicen. Por ejemplo:
class Application:
# Removed code
def _setup_player(self, dungeon):
player = Player.awake()
player.register(self._printer)
dungeon.register(self._printer)
player.awake_in(dungeon)
return player
Claramente, la línea dungeon.register(self._printer)
parece fuera de lugar. Lo vemos por dos razones:
- El método se refiere claramente a
Player
, no aDungeon
. - La llamada a
dungeon.register
introduce una pequeña distorsión visual. Como un granito o una motita en la uniformidad de usos deplayer
.
En este caso la fealdad nos está señalando que hay problemas de cohesión. No todas las líneas del método están contribuyendo a lo mismo.
La solución es bien sencilla: hay que quitar esa línea y llevarla a otro lado. Ahora estos métodos se ven
menos feos Al menos, son más cohesivos.
class Application:
# Removed code
def _setup_player(self, dungeon):
player = Player.awake()
player.register(self._printer)
player.awake_in(dungeon)
return player
def _build_dungeon(self, dungeon_name):
dungeon = self._factory.make(dungeon_name)
dungeon.register(self._printer)
return dungeon
Lios
¿Qué me llama la atención para mal de este fragmento?
def run(self, dungeon_name='game'):
self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
dungeon = self._build_dungeon(dungeon_name)
player = self._setup_player(dungeon)
game = Game(player=player, obtain_input=self._obtain_user_command, printer=self._printer)
dungeon.register(game)
game.run()
Al menos dos cosas:
- La primera línea, que se siente fuera de lugar porque estamos imprimiendo sin usar
Printer
, con un objeto del que solo poblamos una de cuatro propiedades. Además, es una línea muy larga. De nuevo, su aspecto nos hace arrugar el ceño al verla. - El hecho de que tengamos que terminar preparar
dungeon
después de instanciarGame
, por culpa de que antes necesitamos tener aPlayer
. Esto me hace dudar de si es buena idea montarGame
conPlayer
. Posiblemente, tenga más sentido pasarPlayer
al métodorun
. De nuevo, es una línea larga, que se ve fuera de lugar y que, además, provoca que el orden de las líneas no sea adecuado para leer.
Sobre el primer problema volveré luego ya que implica algunos cambios más profundos.
El segundo problema debería ser más fácil de manejar. En primer lugar, pasamos player
al método run
de Game
.
class Game:
# Removed code
def run(self, player):
while not self.finished():
self._player.do(self._input.command())
self._printer.draw()
# Removed code
A continuación, empezamos a usarlo en ese método.
def run(self, player):
while not self.finished():
player.do(self._input.command())
self._printer.draw()
Una vez hemos reemplazado los usos de self._player
por player
, podemos eliminarlo:
class Game:
def __init__(self, player, obtain_input, printer):
self._finished = False
self._input = obtain_input
self._printer = printer
Y ya no lo tenemos que pasar en la construcción.
class Game:
def __init__(self, obtain_input, printer):
self._finished = False
self._input = obtain_input
self._printer = printer
class Application:
# Removed code
def run(self, dungeon_name='game'):
self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
dungeon = self._build_dungeon(dungeon_name)
player = self._setup_player(dungeon)
game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
dungeon.register(game)
game.run(player)
Lo que nos permite reorganizar el código de forma más legible, llevando toda la preparación de Dungeon
a su método:
class Application:
# Removed code
def run(self, dungeon_name='game'):
self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
dungeon = self._build_dungeon(dungeon_name, game)
player = self._setup_player(dungeon)
game.run(player)
def _setup_player(self, dungeon):
player = Player.awake()
player.register(self._printer)
player.awake_in(dungeon)
return player
def _build_dungeon(self, dungeon_name, game):
dungeon = self._factory.make(dungeon_name)
dungeon.register(self._printer)
dungeon.register(game)
return dungeon
Todos estos pasos los hemos dado sin que los tests se resintiesen en ningún momento.
Bonita impresión
Para atacar el problema de la primera línea se me ocurre que sigo viendo algunos aspectos molestos en el modo en que se montan los objetos del juego y el momento en que se montan.
def run(self, dungeon_name='game'):
self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
dungeon = self._build_dungeon(dungeon_name, game)
player = self._setup_player(dungeon)
game.run(player)
Si volvemos a la narrativa del juego, la jugadora se “despierta” en la mazmorra. Es cuando el juego empieza, pero nosotros hacemos eso en la preparación:
def _setup_player(self, dungeon):
player = Player.awake()
player.register(self._printer)
player.awake_in(dungeon)
return player
def _build_dungeon(self, dungeon_name, game):
dungeon = self._factory.make(dungeon_name)
dungeon.register(self._printer)
dungeon.register(game)
return dungeon
Deberíamos moverlo a Game.run
. La preparación en este nivel estaría reservada a las cuestiones más técnicas.
Eso implica que tendríamos que pasar dungeon
a Game.run
, para poder hacer el player.awake_in
.
Básicamente, esto:
class Application:
# Removed code
def run(self, dungeon_name='game'):
self._show_scene(Scene(title="Welcome to the Dungeon", command="", description="", energy="100"))
game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
player = self._setup_player()
dungeon = self._setup_dungeon(dungeon_name)
game.run(player, dungeon)
def _setup_player(self):
player = Player.awake()
player.register(self._printer)
return player
def _setup_dungeon(self, dungeon_name):
dungeon = self._factory.make(dungeon_name)
dungeon.register(self._printer)
return dungeon
Y esto:
class Game:
# Removed code
def run(self, player, dungeon):
dungeon.register(self)
player.awake_in(dungeon)
while not self.finished():
player.do(self._input.command())
self._printer.draw()
# Removed code
Ahora es cuando tratamos el problema de la pantalla de bienvenida. Si me fijo en el código de Game.run
podría tener sentido invocar el dibujo de esa pantalla antes del bucle.
Esa pantalla reflejaría los eventos previos al inicio del bucle… Específicamente, que la jugadora despierta dentro de la mazmorra. Así que el método awake_in
es el lugar adecuado para tirar el evento PlayerAwake
.
def test_notifies_player_awake(self):
fake_observer = FakeObserver()
player = Player.awake_with_energy(EnergyUnit(100))
player.register(fake_observer)
player.awake_in(Dungeon(Rooms()))
self.assertTrue(fake_observer.is_aware_of("player_awake"))
El evento:
class PlayerAwake:
def __init__(self):
pass
def name(self):
return "player_awake"
Y lo manejamos en Printer
.
class Printer:
def __init__(self, show_output):
self.show_output = show_output
self._command = ""
self._description = ""
self._energy = ""
self._title = ""
def notify(self, event):
if event.name() == "player_energy_changed":
self._energy = str(event.energy().value())
# Removed code
elif event.name() == "player_awake":
self._title = "Welcome to the Dungeon"
self._energy = "100"
Y esta solución mantiene los tests pasando y muestra una pantalla de bienvenida, aunque necesita un par de arreglos:
You said:
Welcome to the Dungeon
--------------------------------------
Remaining energy: 100
======================================
Necesitamos introducir un poco de código que controle si algunos campos no necesitan imprimirse:
class RichConsoleShowOutput(ShowOutput):
def put(self, scene):
if scene.command() != "":
print("You said: {}\n".format(scene.command()))
print("{}".format(scene.title()))
print("--------------------------------------")
print("{}".format(scene.description()))
print("Remaining energy: {}".format(scene.energy()))
print("======================================")
Pero uno de los mejores efectos de este cambio es el aspecto que tiene ahora Application
. Aún tenemos cosas que arreglar en la inicialización, pero ahora tenemos métodos cortos, cohesivos y sencillos.
class Application:
def __init__(self, obtain_user_command, show_output, factory, toggles):
self._toggles = toggles
self._obtain_user_command = obtain_user_command
self._printer = Printer(show_output)
self._factory = factory
def run(self, dungeon_name='game'):
player = self._setup_player()
dungeon = self._setup_dungeon(dungeon_name)
game = Game(obtain_input=self._obtain_user_command, printer=self._printer)
game.run(player, dungeon)
def _setup_player(self):
player = Player.awake()
player.register(self._printer)
return player
def _setup_dungeon(self, dungeon_name):
dungeon = self._factory.make(dungeon_name)
dungeon.register(self._printer)
return dungeon
Aquí puedes ver el estado del código en este momento.
Como nota al margen, señalaría que posiblemente algunos eventos se puedan enriquecer con más información. Por ejemplo, este último PlayerAwake
, podría llevar información como el nombre de la mazmorra, así como la energía con la que comienza la jugadora.
El nacimiento de Player
Esto me lleva a un último tema en esta entrega. Al principio quise ser fiel a la narrativa del juego creando un constructor awake
para Player
, pero eso es algo que no ha evolucionado correctamente.
Player
nace antes de despertarse en la mazmorra y es en ese momento cuando se le asignan algunas propiedades iniciales. La más importante, por el momento, es el nivel de energía que normalmente será 100, pero que en algunos tests se manipula por conveniencia.
Así que simplemente he decidido eliminar estos constructores y dejar únicamente el constructor nativo:
class Player:
def __init__(self, starting_energy=EnergyUnit(100)):
self._energy = Energy(starting_energy)
self._subject = Subject()
self._receiver = None
self._holds = None
self._last_command = None
Próximos pasos
En este momento tengo en la cabeza varias ideas que me gustaría explorar.
- Meta programación: hay algunas secciones del programa que podrían beneficiarse de técnicas de meta programación, para registrar automáticamente comandos o diseños de mazmorras.
- Configuración: el diseño de las mazmorras podría beneficiarse de un sistema de configuración en lugar de construir con código. Esto nos permite separar ambos aspectos, facilitando también .
- *Limpieza de tests: muchos tests se basan en capturar eventos, por lo que podría tener sentido unificar el tooling del test para facilitarlo. Lo mismo para tests que buscan verificar la interacción o la salida por la consola.
En lo que toca a prestaciones del juego:
- Introducir nuevos objetos con nuevas posibilidades de interacción: puertas que se abren con llaves que debemos recoger en nuestro recorrido, armas, etc., y en algún momento enemigos.
- Jordi Martínez ha sugerido en artículos anteriores sobre la posibilidad de que la jugadora no tenga una visión completa de la mazmorra, así como que las habitaciones o celdas tengan espacio. Son ideas interesantes que podría explorar.
- Dar soporte para que las celdas y objetos tengan descripciones largas, que permitan crear historias más interesantes y sugerentes.