Dungeon 6. Builders al rescate

por Fran Iglesias

Después de arreglar el código en la primera parte de la iteración, vamos por la chicha y creamos una mazmorra algo más complicada.

Qué es una mazmorra

En nuestro juego, una mazmorra es una colección de habitaciones, o celdas, que pueden estar conectadas entre sí, a través de puertas. Una de las habitaciones tiene la puerta que conduce a la salida.

Por el momento vamos a centrarnos en la capacidad de movernos por la mazmorra, recorriendo habitaciones. La idea que tengo en la cabeza es bastante sencilla. Para moverte a otra celda necesitas pasar por una pared que tenga puerta. Esa pared sabrá cuál es su celda de destino.

Por supuesto, tiene que haber algún objeto que sepa en qué celda se encuentra la jugadora en todo momento. He pensado que esa información se puede pasar en ActionResult.

Para probarlo, voy a empezar con un modelo de mazmorra muy reducido. Por supuesto, también tengo que crear una representación de la mazmorra completa. Vamos allá:

Podría empezar con una mazmorra de dos celdas. Algo así:

+---+---+
| 0 : 1 |
+...+---+

Voy a representar las puertas con : y ... porque me parece que queda más claro.

En resumen:

  • La mazmorra tiene dos celdas: 0 y 1
  • La celda 0 tiene una puerta en la pared east que lleva a 1
  • La celda 1 tiene una puerta en la pared west que lleva a 0
  • La celda 0 tiene la salida en la pared south

Para construir la mazmorra he pensado que se podría hacer de la siguiente manera: el objeto Dungeon se instanciaría con el número de celdas que va a contener:

dungeon = Dungeon(2)

Luego se conectan las celdas mediante instrucciones de este estilo, aunque la interfaz está por ver, ya que parece muy procedural:

dungeon.connect(0, "east", 1)
dungeon.connect(1, "west", 0)

Y se indica donde está la salida:

dungeon.exit(0, "south")

Y también, dónde se empieza:

dungeon.start(1)

Si la mazmorra se construye correctamente, la secuencia de instrucciones:

go west
go south

Debería dar como resultado que la jugadora sale de la mazmorra.

Hagamos un spike

Para desarrollar esto voy a hacer un spike. Un spike es un desarrollo exploratorio con el que probar una forma de implementar algo para saber qué implicaciones tiene. El código no se va a utilizar en el proyecto y puedes desarrollarlo con prácticas un poco más laxas. Lo que quieres obtener respuestas a dudas.

Así que vamos a empezar este spike con un test basándonos en lo que acabamos de plantear. ¿Desde dónde podemos hacer el test? Creo que lo más fácil es testear contra Dungeon. Hacerlo desde Game o Application no nos aporta gran cosa en este momento.

Este es un esqueleto para construir este test:

class DungeonTestCase(unittest.TestCase):
    def test_something(self):
        dungeon = self.build_dungeon()

        dungeon.go('west')
        exited = dungeon.go('south')

        self.assertTrue(exited.is_finished())

    def build_dungeon(self):
        dungeon = Dungeon(2)
        dungeon.connect(0, 'east', 1)
        dungeon.connect(1, 'west', 0)
        dungeon.exit(0, 'south')
        dungeon.start(1)

        return dungeon

Ejecuto el test y voy creando los métodos que necesito tener. Como decía antes, aún no estoy decidido sobre la interfaz. De hecho, puede que prefiera hacer un DungeonBuilder, pero de momento vamos a explorarlo de esta manera.

Esta es la solución que he implementado para que pasen los tests. Aquí Dungeon:

from dungeon.room import Room
from dungeon.wall import DoorTo, Exit


class Dungeon:
    def __init__(self, max_rooms):
        rooms = dict()
        for key in range(max_rooms):
            rooms[key] = Room()

        self._current = None
        self._rooms = rooms

    def go(self, direction):
        cell = self._rooms[self._current]
        return cell.go(direction)

    def look(self, focus):
        cell = self._rooms[self._current]
        return cell.look(focus)

    def connect(self, origin, direction, target):
        origin_room = self._rooms[origin]
        target_room = self._rooms[target]

        if direction == 'north':
            self._rooms[origin].north = DoorTo(origin_room.north, target_room)
        elif direction == 'south':
            self._rooms[origin].south = DoorTo(origin_room.south, target_room)
        elif direction == 'east':
            self._rooms[origin].east = DoorTo(origin_room.east, target_room)
        elif direction == 'west':
            self._rooms[origin].west = DoorTo(origin_room.west, target_room)

    def exit(self, origin, direction):
        origin_room = self._rooms[origin]

        if direction == 'north':
            self._rooms[origin].north = Exit(origin_room.north)
        elif direction == 'south':
            self._rooms[origin].south = Exit(origin_room.south)
        elif direction == 'east':
            self._rooms[origin].east = Exit(origin_room.east)
        elif direction == 'west':
            self._rooms[origin].west = Exit(origin_room.west)

    def start(self, start_room):
        self._current = start_room

Y aquí, Wall:

from dungeon.command.action_result import ActionResult


class Wall:
    def go(self):
        return ActionResult("You hit a wall")

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


class Exit(Wall):
    def __init__(self, origin):
        self._origin = origin

    def go(self):
        return ActionResult("Congrats. You're out")

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


class DoorTo(Wall):
    def __init__(self, origin, target):
        self._origin = origin
        self._target = target

    def go(self):
        return ActionResult("You are in a new room")

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

Esto es bastante código para un solo test, es verdad. Pero no es demasido complicado. El enfoque que he planteado es que existan decoradores de Wall que le añadan propiedades. Por ejemplo, tener puertas que conectan con otras salas. También he convertido Exit en decorador.

Los tests todavía no pasan. Tengo que modificar ActionResult para que pueda devolver la celda en que se encuentra la jugadora y que Dungeon pueda ser notificada en consecuencia.

class ActionResult:
    def __init__(self, message, room = None):
        self._message = message
        self._room = room

    def message(self):
        return self._message

    def room(self):
        return self._room

    def is_finished(self):
        return self._message == "Congrats. You're out"

De esta forma ActioResult no necesita que le pasemos room, de modo que nos basta cambiar DoorTo:

class DoorTo(Wall):
    def __init__(self, origin, target):
        self._origin = origin
        self._target = target

    def go(self):
        return ActionResult("You are in a new room", self._target)

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

Y Dungeon lo utiliza así:

    def go(self, direction):
        cell = self._rooms[self._current]
        result = cell.go(direction)
        if result.room() is None:
            return result
        for k, v in self._rooms.items():
            if v == result.room():
                self._current = k
                return result

Con esto, podemos movernos por las celdas de la mazmorra. Pero tenemos que mejorar este código un montón. Veamos algunas conclusiones del spike:

  • Necesitamos un DungeonBuilder, la lógica de construcción en Dungeon se nota fuera de lugar. Se trataría de generar la colección de celdas y pasarla a Dungeon en construcción, así como conectarlas y marcar tanto la salida como el punto de inicio.
  • El enfoque de decoradores para añadir características a las paredes parece una buena aproximación.
  • Room puede mejorar su estructura interna. En lugar de tener cuatro propiedades para organizar las habitaciones, podríamos organizarlas en un diccionario. Junto a eso, necesitamos algún tipo de Enumerable que represente los puntos cardinales para referirnos a una dirección. Esta es una reflexión relacionada con la complejidad de métodos como connect o exit en Dungeon.
  • ActionResult puede recoger información sobre la habitación de destino tras una acción, además del mensaje y quizá otras informaciones. Pero necesita algunas mejoras.

Vayamos por partes. Lo primero es borrar los cambios hechos en el spike. Si quieres conservarlos siempre puedes dejarlos en una rama del repositorio, aunque igual no merece la pena.

Mejorando el diseño de Room

Para mejorar el diseño de Room quiero introducir un diccionario que almacene las paredes. De este modo consigo varias cosas, entre ellas, que sea más fácil referenciar una pared específica de la habitación.

Y para hacerlo con más seguridad, lo ideal es tener algún tipo de objeto enumerable que me garantice que únicamente manejo los valores adecuados. En Python tenemos enum.py.

class Dir(Enum):
    N = "north"
    S = "south"
    E = "east"
    W = "west"

Ahora queremos empezar a usarlo, así que vamos a crear un test de Room que nos ayude:

class TestRoom(TestCase):
    def test_wall_in_all_directions(self):
        room = Room()
        result = room.go(Dir.N)
        self.assertEqual("Congrats. You're out", result.message())
        result = room.go(Dir.E)
        self.assertEqual('You hit a wall', result.message())
        result = room.go(Dir.S)
        self.assertEqual('You hit a wall', result.message())
        result = room.go(Dir.W)
        self.assertEqual('You hit a wall', result.message())

Empezamos así:

class Room:
    def __init__(self):
        self.north = Exit()
        self.south = Wall()
        self.east = Wall()
        self.west = Wall()
        self._walls = {
            Dir.N: Exit(),
            Dir.E: Wall(),
            Dir.S: Wall(),
            Dir.W: Wall()
        }

    def go(self, direction):
        wall = self._walls[direction]
        return wall.go()

    # Removed code

Un nuevo test:

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

        room = Room()
        result = room.look('around')

        self.assertEqual(description, result.message())

Y este es el resultado:

class Room:
    def __init__(self):
        self._walls = {
            Dir.N: Exit(),
            Dir.E: Wall(),
            Dir.S: Wall(),
            Dir.W: Wall()
        }

    def go(self, direction):
        wall = self._walls[direction]
        return wall.go()

    def look(self, argument):
        response = ""
        response += "North: " + self._walls[Dir.N].look().message() + "\n"
        response += "East: " + self._walls[Dir.E].look().message() + "\n"
        response += "South: " + self._walls[Dir.S].look().message() + "\n"
        response += "West: " + self._walls[Dir.W].look().message() + "\n"

        response += "That's all" + "\n"
        return ActionResult(response)

Para que la aplicación siga funcionando hay que aplicar algún cambio más, para convertir las direcciones en texto, en valores del enumerable.

class Dungeon:
    def __init__(self):
        self.start = Room()

    def go(self, direction):
        return self.start.go(Dir(direction))

    def look(self, focus):
        return self.start.look(focus)

Con esto, podemos hacer un commit, antes de continuar. Fíjate que nuestro Dungeon está hardcoded todavía, para mantener los tests pasando. Así que es hora de abordar la creación del DungeonBuilder y poder crear juegos más interesantes.

Dungeon Builder

Lo primero que voy a hacer es introducir el DungeonBuilder para que le pase una Dungeon a Game, sin ninguna lógica de construcción todavía. Simplemente, quiero tenerlo y que el juego lo use. De este modo, además, consigo tener un único punto de construcción de los elementos del juego.

class DungeonBuilder:
    def __init__(self):
        pass

    def build(self):
        return Dungeon()

Y lo uso:

class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        dungeon_builder = DungeonBuilder()
        dungeon = dungeon_builder.build()
        game = Game()
        game.start(dungeon)
        action_result = ActionResult("")
        while not action_result.is_finished():
            command = self._obtain_user_command.command()
            action_result = game.do_command(command)
            self._show_output.put(str(command))
            self._show_output.put(action_result.message())

Elimino el default en Game, para que los tests que fallen me chiven donde tengo que introducir el DungeonBuilder.

class Game:
    def __init__(self):
        self.dungeon = None

    def start(self, dungeon):
        self.dungeon = dungeon

    def do_command(self, command):
        return command.do(self.dungeon)

Hago lo mismo en los tests que lo necesiten:

class OneRoomDungeonTestCase(unittest.TestCase):
    def setUp(self):
        builder = DungeonBuilder()
        dungeon = builder.build()
        self.game = Game()
        self.game.start(dungeon)

Fijo este cambio con un commit. Y ya podemos empezar.

Ahora mismo, las celdas de una mazmorra se generan dentro de la propia mazmorra. Pero para que se puedan generar diferentes mazmorras en realidad tendríamos que pasárselas en algún momento, idealmente en la construcción para no necesitar métodos específicos.

Dungeon solo tiene una Room. Vamos a cambiar eso por una colección, pero la vamos a encapsular a fin de no acoplarnos a la estructura de datos nativa que en este caso será un diccionario. Para poder referirnos a cada habitación, podremos usar un identificador. Además, vamos a hacer inmutable esta estructura.

class TestRooms(TestCase):
    def setUp(self):
        self.rooms = Rooms()

    def test_new_collection_created_empty(self):
        self.assertEqual(0, self.rooms.count())

    def test_can_set_rooms_with_identifier(self):
        room = Room()
        rooms = self.rooms.set('aRoom', room)
        self.assertEqual(1, rooms.count())

    def test_can_get_specific_room(self):
        first = Room()
        second = Room()

        self.rooms = self.rooms
            .set('first', first)
            .set('second', second)

        self.assertEqual(first, self.rooms.get('first'))
        self.assertEqual(second, self.rooms.get('second'))

Aquí está Rooms.

class Rooms:
    def __init__(self):
        self._rooms = dict()

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

    def count(self):
        return len(self._rooms)

    def set(self, key, room):
        cloned = self
        cloned._rooms[key] = room
        return cloned

Y aquí empezamos a usarla:

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

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

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

    def _current_room(self):
        return self._rooms.get(self._current)


class DungeonBuilder:
    def __init__(self):
        pass

    def build(self):
        rooms = Rooms()
        rooms.set('start', Room())
        return Dungeon(rooms)

Se puede ver un problema de acoplamiento gordo aquí pues Dungeon sabe que hay una celda llamada start, luego nos ocuparemos.

Esto nos lleva al siguiente problema. La única Room que usamos en el juego tiene hardcoded las paredes, por lo que siempre serán las mismas. Para que sean configurables necesitamos pasarlas desde fuera. Anteriormente, las habíamos modelado como una colección, y haremos lo mismo que con Rooms, encapsulando el diccionario en una clase.

Esta colección la voy a iniciar con todas las paredes siendo… paredes por defecto. Así ayudará a que sea más fácil configurarlas.

class Walls:
    def __init__(self):
        self._walls = {
            Dir.N: Wall(),
            Dir.E: Wall(),
            Dir.S: Wall(),
            Dir.W: Wall()
        }

    def get(self, direction):
        return self._walls[direction]


class TestWalls(TestCase):
    def test_walls_collection_starts_with_all_walls(self):
        walls = Walls()
        self.assertIsInstance(walls.get(Dir.N), Wall)
        self.assertIsInstance(walls.get(Dir.S), Wall)
        self.assertIsInstance(walls.get(Dir.E), Wall)
        self.assertIsInstance(walls.get(Dir.W), Wall)

Por supuesto, querré poder poner paredes con puertas. De momento solo tenemos la puerta de salida, pero eso acabará pronto.

    def test_can_set_a_wall(self):
        self.walls = self.walls.set(Dir.N, Exit())
        self.assertIsInstance(self.walls.get(Dir.N), Exit)

Y ya tenemos Walls.

class Walls:
    def __init__(self):
        self._walls = {
            Dir.N: Wall(),
            Dir.E: Wall(),
            Dir.S: Wall(),
            Dir.W: Wall()
        }

    def get(self, direction):
        return self._walls[direction]

    def set(self, direction, wall):
        cloned = self
        cloned._walls[direction] = wall
        return cloned

Preparo Room para poder pasarle la nueva colección.

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):
        response = ""
        response += "North: " + self._walls.get(Dir.N).look().message() + "\n"
        response += "East: " + self._walls.get(Dir.E).look().message() + "\n"
        response += "South: " + self._walls.get(Dir.S).look().message() + "\n"
        response += "West: " + self._walls.get(Dir.W).look().message() + "\n"

        response += "That's all" + "\n"
        return ActionResult(response)

Esto romperá unos cuantos tests, pero únicamente necesito tocar en un par de sitios. Uno es el archivo de tests de Room y el otro es el DungeonBuilder.

class DungeonBuilder:
    def __init__(self):
        pass

    def build(self):
        rooms = Rooms()
        walls = Walls()
        walls.set(Dir.N, Exit())
        rooms.set('start', Room(walls))
        return Dungeon(rooms)

Ya casi estamos. Hago un commit porque entre una cosa y otra he cambiado muchos archivos, ya que he tenido que mover Dir al suyo propio para evitar una dependencia circular.

Con DungeonBuilder quiero empezar a crear un lenguaje para definir las mazmorras. Así que vamos a hacer un test.

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

Este test reproduce la mazmorra que usamos actualmente en el juego. Para implementarlo hago esto:

class DungeonBuilder:
    def __init__(self):
        self._names = []
        self._walls = dict()

    def build(self):
        rooms = Rooms()
        walls = Walls()
        w = self._walls[self._names[0]]
        walls.set(w['direction'], w['wall'])
        rooms = rooms.set(self._names[0], Room(walls))

        return Dungeon(rooms)

    def add(self, room_name):
        self._names.append(room_name)

    def set(self, room_name, direction, wall):
        self._walls[room_name] = {
            'direction': direction,
            'wall': wall
        }

Con este código el test pasa. Para que los demás puedan pasar también, modifico Application:

class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())
        dungeon = builder.build()
        game = Game()
        game.start(dungeon)
        action_result = ActionResult("")
        while not action_result.is_finished():
            command = self._obtain_user_command.command()
            action_result = game.do_command(command)
            self._show_output.put(str(command))
            self._show_output.put(action_result.message())

Y este otro test:

class OneRoomDungeonTestCase(unittest.TestCase):
    def setUp(self):
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())
        dungeon = builder.build()
        self.game = Game()
        self.game.start(dungeon)

    def execute_user_action(self, action):
        return self.game.do_command(Command.from_user_input(action)).message()

    def test_player_finds_easy_way_out(self):
        self.assertEqual("Congrats. You're out", self.execute_user_action("go north"))

    def test_player_tries_closed_wall(self):
        self.assertEqual("You hit a wall", self.execute_user_action("go south"))

    def test_player_tries_another_closed_wall(self):
        self.assertEqual("You hit a wall", self.execute_user_action("go east"))

    def test_unknown_command(self):
        self.assertEqual("I don't understand", self.execute_user_action("foo bar"))

    def test_player_can_look_around(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
"""
        self.assertEqual(description, self.execute_user_action("look around"))

Ahora podré seguir trabajando aisladamente en DungeonBuilder.

Vamos a permitir añadir más puertas a una misma habitación. De momento, lo suficiente para hacer evolucionar el código del builder.

    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.is_finished())
        result = dungeon.go('south')
        self.assertTrue(result.is_finished())

Helo aquí:

class DungeonBuilder:
    def __init__(self):
        self._names = []
        self._walls = dict()

    def build(self):
        rooms = Rooms()
        walls = Walls()
        for direction, wall in self._walls[self._names[0]].items():
            walls.set(direction, wall)
        rooms = rooms.set(self._names[0], Room(walls))

        return Dungeon(rooms)

    def add(self, room_name):
        self._names.append(room_name)
        self._walls[room_name] = dict()

    def set(self, room_name, direction, wall):
        self._walls[room_name][direction] = wall

También queremos poder añadir varias habitaciones:

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

Este test falla porque no damos soporte a más de una habitación y, por eso, Dungeon nunca llega a tener una llamada ‘start’. Únicamente tenemos que añadir la iteración.

class DungeonBuilder:
    def __init__(self):
        self._names = []
        self._walls = dict()

    def build(self):
        rooms = Rooms()
        for name in self._names:
            walls = Walls()
            for direction, wall in self._walls[name].items():
                walls.set(direction, wall)
            rooms = rooms.set(name, Room(walls))

        return Dungeon(rooms)

    def add(self, room_name):
        self._names.append(room_name)
        self._walls[room_name] = dict()

    def set(self, room_name, direction, wall):
        self._walls[room_name][direction] = wall

Nos queda poder conectar habitaciones. Pero para ello necesitamos crear paredes que tengan puertas y ser capaces de mover a la jugadora dentro de la mazmorra. Así que voy a hacer un commit con todos los últimos cambios antes de continuar.

Para poder moverse entre habitaciones necesitamos varias cosas:

  • Poder poner puertas en las paredes que conecten con otra habitación, que podemos referenciar por su nombre
  • Que ActionResult pueda llevar la información de la celda de destino
  • Que dungeon pueda recoger esta información y cambiar su referencia a la celda actual

Empezaría por ActionResult. Básicamente, es una especie de evento y podríamos trabajar con él basándonos en esa idea. La información que tiene que llevar es:

  • El mensaje que describe el resultado de la acción.
  • La celda de destino, en caso de que la acción implique movimiento, algo que solo ocurre si atraviesa una puerta.
  • Si la jugadora ha salido o terminado el juego. Por defecto sería que no.

Este mensaje puede pasarse a varios componentes del juego, que podrían usar la información con diferentes finalidades. Por ejemplo, Dungeon escucha si la jugadora ha cambiado de habitación. Por su parte, Application escucha el mensaje de la acción para mostrarlo, así como si la jugadora ha terminado el juego.

Esto podría llevarnos a pensar que tal vez necesitemos generar distintos mensajes, dependiendo del significado y del destinatario. Pero creo que en este momento es prematuro plantearlo así.

Sin embargo, creo que puede ser buena idea hacer que ActionResult tenga varios constructores, de modo que tengamos significado y posibilidad de replantearlo más adelante como factorías de eventos si es el caso.

  • player_moved: indicaría que la jugadora ha cambiado de celda.
  • player_exited: indicaría que ha logrado salir.
  • player_acted: indicaría una acción genérica que no supone cambiar de celda o salir de la mazmorra.
class TestActionResult(TestCase):
    def test_generic_action_result(self):
        result = ActionResult.player_acted("message")

        self.assertFalse(result.is_finished())
        self.assertIsNone(result.moved_to())

Implementación:

class ActionResult:
    def __init__(self, message):
        self._message = message

    @classmethod
    def player_acted(cls, message):
        return cls(message)

    def message(self):
        return self._message

    def is_finished(self):
        return self._message == "Congrats. You're out"

    def moved_to(self):
        return None

Test:

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

        self.assertFalse(result.is_finished())
        self.assertIsNone(result.moved_to())

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

        self.assertFalse(result.is_finished())
        self.assertEquals('room', result.moved_to())

Implementación:

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

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

    def __init__(self, message, destination=None):
        self._message = message
        self._destination = destination

    def message(self):
        return self._message

    def is_finished(self):
        return self._message == "Congrats. You're out"

    def moved_to(self):
        return self._destination

Y, finalmente, otro test:

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

        self.assertFalse(result.is_finished())
        self.assertIsNone(result.moved_to())

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

        self.assertFalse(result.is_finished())
        self.assertEquals('room', result.moved_to())

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

        self.assertTrue(result.is_finished())
        self.assertIsNone(result.moved_to())

Implementación:

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

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

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

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

    def message(self):
        return self._message

    def is_finished(self):
        return self._exited

    def moved_to(self):
        return self._destination

Ahora vamos a usar estas factorías en los lugares adecuados. Por ejemplo:

class Wall:
    def go(self):
        return ActionResult.player_acted("You hit a wall")

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


class Exit(Wall):
    def go(self):
        return ActionResult.player_exited("Congrats. You're out")

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

Hay un par de ocasiones en las que player_acted, no tiene un buen significado. Por ejemplo, en Application:

    action_result = ActionResult.player_acted("")

en este caso, podría ser algo así como:

    action_result = ActionResult.game_started()

Así que vamos a ello:

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

        self.assertFalse(result.is_finished())
        self.assertIsNone(result.moved_to())
class ActionResult:
    
    # Removed code

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

    # Removed code

Consolidamos estos cambios y, en principio, con esto ya estamos listas para introducir la capacidad de moverse entre celdas. Para ello:

  • Introducir un subtipo de Wall
  • Añadir un método en DungeonBuilder para conectar celdas

La conexión entre celdas tiene algunas cuestiones interesantes que considerar. Por ejemplo, ¿es una puerta de doble sentido? Si conectamos la habitación “A” con la “B” por la puerta norte, tenemos que conectar la habitación “B” con la “A” por la puerta sur. Por ejemplo, podríamos tener puertas que solo comunican en un sentido, o que para volver tienen llave. Además, las habitaciones no tienen que estar físicamente juntas.

Por el momento, empezaremos con el comportamiento más sencillo, que consiste en poder comunicar dos celdas en ambos sentidos.

Introducimos el objeto Door.

class TestWalls(TestCase):
    
    # Removed code

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

        self.assertEqual('destination', result.moved_to())

Que podemos implementar así.

class Door(Wall):
    def __init__(self, destination):
        self._destination = destination

    def go(self):
        return ActionResult.player_moved("You move to room", self._destination)

Esto debería ser suficiente para poder crear una mazmorra con varias celdas conectadas. Para el test de ejemplo vamos a crear una mazmorra de dos habitaciones como esta:

+---+
|   =>  (101)
+...+
| @ |   (start)
+---+

En el test nos movemos al norte y al sur para demostrar que están comunicadas y salimos hacia el este en la habitación 101.

class TestDungeonBuilder(TestCase):
    
    # Removed code

    def test_can_connect_rooms(self):
        builder = DungeonBuilder()
        builder.add('101')
        builder.add('start')
        builder.connect('start', Dir.N, '101')
        builder.set('101', Dir.E, Exit())

        dungeon = builder.build()
        dungeon.go('north')
        dungeon.go('south')
        dungeon.go('north')
        result = dungeon.go('east')
        self.assertTrue(result.is_finished())

Para hacer esto voy a introducir una feature toggle, que me permita añadir código en una situación específica. Lo voy a hacer de una manera bastante basta, pero me servirá para añadir código sin romper lo anterior.

    def test_can_connect_rooms(self):
        builder = DungeonBuilder()
        
        # This is the feature toggle
        builder.allow_connect = True
        
        builder.add('101')
        builder.add('start')
        builder.connect('start', Dir.N, '101')
        builder.set('101', Dir.E, Exit())

        dungeon = builder.build()
        dungeon.go('north')
        dungeon.go('south')
        dungeon.go('north')
        result = dungeon.go('east')
        self.assertTrue(result.is_finished())

Y preparo DungeonBuilder de esta manera:

class DungeonBuilder:
    def __init__(self):
        self._names = []
        self._walls = dict()
        self.allow_connect = False

    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)

            if self.allow_connect:
                pass

            rooms = rooms.set(name, Room(walls))

        return Dungeon(rooms)

    def add(self, room_name):
        self._names.append(room_name)
        self._walls[room_name] = dict()

    def set(self, room_name, direction, wall):
        self._walls[room_name][direction] = wall

    def connect(self, origin, direction, target):
        pass

Mi primer intento es lo más simple que puedo:

class DungeonBuilder:
    def __init__(self):
        self._names = []
        self._walls = dict()
        self.allow_connect = False

    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)

            if self.allow_connect:
                if name == 'start':
                    walls = walls.set(Dir.N, Door('101'))
                if name == '101':
                    walls = walls.set(Dir.S, Door('start'))

            rooms = rooms.set(name, Room(walls))

        return Dungeon(rooms)

    def add(self, room_name):
        self._names.append(room_name)
        self._walls[room_name] = dict()

    def set(self, room_name, direction, wall):
        self._walls[room_name][direction] = wall

    def connect(self, origin, direction, target):
        pass

Esto fallará porque aún no he implementado nada en Dungeon que sepa usar el retorno de ActionResult.

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

    def go(self, direction):
        result = self._current_room().go(Dir(direction))
        if result.moved_to() is not None:
            self._current = result.moved_to()
        return result

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

    def _current_room(self):
        return self._rooms.get(self._current)

Y con este cambio, el movimiento entre celdas funciona. Ahora necesitamos desarrollar algo más general en DungeonBuilder.

class DungeonBuilder:
    def __init__(self):
        self._names = []
        self._walls = dict()
        self.allow_connect = False

    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)

            rooms = rooms.set(name, Room(walls))

        return Dungeon(rooms)

    def add(self, room_name):
        self._names.append(room_name)
        self._walls[room_name] = dict()

    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, self._opposite(direction), Door(origin))

    @staticmethod
    def _opposite(direction):
        opposite = {
            Dir.N: Dir.S,
            Dir.S: Dir.N,
            Dir.E: Dir.W,
            Dir.W: Dir.E
        }

        return opposite[direction]

Y probamos a activar la feature toggle para todos los casos:

class DungeonBuilder:
    def __init__(self):
        self._names = []
        self._walls = dict()
        self.allow_connect = True

De este modo todos los tests siguen pasando, lo que indica que la conexión entre celdas funciona y, por tanto, es posible ya crear mazmorras complejas.

Retiro el feature toggle y hago un commit con el estado actual. Después, haré otro con pequeños refactors aquí y allá.

Por supuesto, el siguiente y último paso de esta iteración será crear una mazmorra más interesante para el juego.

Para empezar, voy a crear una mazmorra de 5x5. Más o menos esta es la representación. He bautizado las celdas anónimas con números para tener una forma de referirnos a ellas. He usado los nombres ‘start’ y ‘exit’ para referirme a las celdas significativas. Recuerda que, de momento, ‘start’ indica la posición por defecto de la jugadora en la mazmorra.

+---+---+---+---+---+
| 0 | 1   2   3   4 |
+   +---+   +---+   +
| 5 | 6   7 | 8   9 |
+   +   +---+   +   +
|10  11  12 |13 |    =>
+---+   +---+   +---+
|14 |15 |16  17  18 |
+   +   +---+---+   +
|19  @   20 |21  22 |
+---+---+---+---+---+ 

@  (start)
=> (exit) 

No es tremendamente complicada, pero es suficiente para demostrar que es posible jugar.

Vamos a ver qué habría que hacer. Lo primero es separar las líneas que construyen la mazmorra en un método de Application, de tal modo que la creación de la mazmorra, que son detalles, no nos distraiga del flujo principal:

class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())
        dungeon = builder.build()
        game = Game()
        game.start(dungeon)
        action_result = ActionResult.player_acted("")
        while not action_result.is_finished():
            command = self._obtain_user_command.command()
            action_result = game.do_command(command)
            self._show_output.put(str(command))
            self._show_output.put(action_result.message())

Quedaría así:

class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        dungeon = self._build_dungeon()
        game = Game()
        game.start(dungeon)
        action_result = ActionResult.player_acted("")
        while not action_result.is_finished():
            command = self._obtain_user_command.command()
            action_result = game.do_command(command)
            self._show_output.put(str(command))
            self._show_output.put(action_result.message())

    def _build_dungeon(self):
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())
        dungeon = builder.build()
        return dungeon

Ahora ya solo sería cuestión de definir la mazmorra con el builder. Es un proceso más tedioso que complicado:

    def _build_dungeon(self):
        builder = DungeonBuilder()
        for cell in range(0, 22):
            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('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('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())
        dungeon = builder.build()
        return dungeon

Ya hora vamos a probarlo. ¿Se podría hacer un test automático de esto que nos sirva para demostrar que hay una ruta de salida? La verdad es que sí. Teniendo el mapa se pueden ver los pasos necesarios para salir. Volveré a eso en otro momento.

Ahora pruebo el juego real y observo que cuando pido un look around no me describe el aspecto real de la celda. Reviso y me doy cuenta de que Door, no tiene método para look y, por tanto, va a mostrar siempre el mensaje que tiene Wall por defecto.

Ahora sí:

❯ python -m dungeon
Welcome to the Dungeon

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

North: There is a door
East: There is a door
South: There is a wall
West: There is a door
That's all


What should I do? >

Voy siguiendo el mapa y descubro algunas celdas mal conectadas y aprovecho para corregir estas conexiones. Finalmente, consigo salir. Algunas observaciones interesantes:

  • Tengo que introducir el comando look around completo para que el programa no falle. Así que debería poder permitir que el comando fuese simplemente look.
  • El mensaje de moverse a una habitación debería ser más bien “You moved to a new room”. Y, quizá añadir el nombre de la celda. O dar soporte para poder darle un nombre a la celda diferente a su identificador.
  • El test de Application es dependiente de que la mazmorra tenga una sola habitación, como hasta ahora, así que sería necesario poder definir varias mazmorras y permitir configurar desde fuera cuál de ellas usar.

Para este último punto, creo que podría introducir un concepto como DungeonFactory, en el que pueda generar distintas mazmorras. Application podría recibir un parámetro que nos indique con cuál vamos a jugar. Así que el método Application._build_dungeon() se moverá a este nuevo DungeonFactory.

Quedaría algo así:

class DungeonFactory:
    def __init__(self):
        pass

    def make(self, dungeon_name):
        if dungeon_name == 'test':
            return self._build_test()
        if dungeon_name == 'game':
            return self._build_dungeon()

    def _build_test(self):
        builder = DungeonBuilder()
        builder.add('start')
        builder.set('start', Dir.N, Exit())
        return builder.build()

    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.E, '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())
        return builder.build()

Y los cambios en Application para darle soporte:

class Application:
    def __init__(self, obtain_user_command, show_output, dungeon_name='game'):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output
        self._dungeon_name = dungeon_name

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        dungeon = self._build_dungeon()
        game = Game()
        game.start(dungeon)
        action_result = ActionResult.player_acted("")
        while not action_result.is_finished():
            command = self._obtain_user_command.command()
            action_result = game.do_command(command)
            self._show_output.put(str(command))
            self._show_output.put(action_result.message())

    def _build_dungeon(self):
        factory = DungeonFactory()
        return factory.make(self._dungeon_name)

Esto nos permite usar una mazmorra específica para estos tests, como por ejemplo:

class TestApplication(TestCase):
    def test_should_show_title(self):
        obtain_user_command = FixedObtainUserCommand("go north")
        show_output = TestShowOutput()

        app = Application(obtain_user_command, show_output, 'test')
        app.run()

        self.assertIn("Welcome to the Dungeon", show_output.contents())

Los otros cambios sugeridos se solucionan más fácilmente aún.

Respecto al problema del look around, queremos esto:

class TestCommand(TestCase):
    def test_allow_look_command_with_no_parameter(self):
        command = Command.from_user_input('look')
        expected = Command('look', 'around')
        self.assertEquals(CommandMatcher(expected), command)


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

    def __eq__(self, other):
        return self.expected._command == other._command and
               self.expected._argument == other._argument

Lo solucionamos de esta manera, que es un poco sucia, pero nos vale por el momento:

class Command:

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

    @staticmethod
    def from_user_input(user_input):
        try:
            command, argument = user_input.split(" ", 1)
        except ValueError:
            command = user_input
            argument = "around"

        if command != "go" and command != "look":
            return InvalidCommand(user_input)

        return Command(command, argument)

    def do(self, dungeon):
        if self._command == "go":
            return dungeon.go(self._argument)
        if self._command == "look":
            return dungeon.look(self._argument)

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

Por último, retocamos el mensaje cuando nos movemos de habitación, para que nos indique donde estamos:

class Door(Wall):
    def __init__(self, destination):
        self._destination = destination

    def go(self):
        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")

Con este resultado:

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

You move to room '15'

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

You hit a wall

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

You move to room 'start'

What should I do? >

Próximos pasos

Con esta iteración logramos un gran hito del desarrollo del juego como es tener una mazmorra lo bastante grande como para ser suficientemente interesante. Además, tenemos potencial para generar nuevas mazmorras más complicadas.

Este es el estado del código en este momento.

Quedan varios temas por arreglar. Por ejemplo, necesitamos poder prevenir algunos errores que ahora no estamos controlando, sobre todo para que la experiencia de juego no se interrumpida por mensajes de error que nos puedan sacar del juego.

También necesitamos probarlo para descubrir cómo lo usan las jugadoras y qué feedback nos proporcionan.

Por otro lado, este es un buen momento para hacer una revisión del código y refactorizar cuestiones de organización, nombres, etc. Esto lo reflejaré en el próximo artículo.

¿Y cuál debería ser la próxima entrega de valor? Una opción sería introducir más diseños de mazmorras, de modo que las jugadoras puedan escoger o que se seleccione una al azar en cada partida.

Otra opción sería empezar a incluir algún tipo de elemento que ayude a aumentar la dificultad, como podrían ser:

  • Descontar energía, de modo que haya un máximo de movimientos posible para salir de la mazmorra. También podríamos tener power-ups para recuperarla.
  • Introducir puertas trampa, que me lleven a lugares no esperados de la mazmorra. Es decir, en lugar de pasar a la habitación obvia, volver a llevarnos al principio o a una habitación al azar, etc.
  • Introducir enemigos.

Siguiente paso, que más que un paso es una reflexión

November 16, 2022

Etiquetas: python   dungeon   good-practices  

Temas

good-practices

refactoring

php

testing

tdd

python

blogtober19

design-principles

design-patterns

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

tips

hexagonal

ddd

bbdd

soft-skills

books

oop

javascript

api

sql

ethics

typescript

swift

java

agile