El código fuente completo (licencia GPL) e instrucciones de puesta en marcha en este repositorio:

https://codeberg.org/pamplina/chess-puzzle-bot

Motivación

Lichess es un proyecto open source y sin ánimo de lucro que ha creado un sitio de ajedrez colosal, donde encontrar todo tipo de recursos para aprender y estudiar, además de jugar partidas y torneos online. Se trata de una alternativa libre y gratuita a la hegemonía de chess.com, que cada vez está reuniendo más adeptos.

https://lichess.org

Una de las características open source de Lichess es que sus bases de datos se pueden descargar y usar libremente, incluida la de ejercicios, que contiene millones de ellos, generados a partir de partidas online jugadas en el propio sitio.

Así pues, se me ocurrió utilizar esos ejercicios para hacer un bot en Mastodon, y de paso explorar la programación de bots interactivos, que es algo que no había hecho hasta ahora.

Para muchos de los detalles de la creación de un bot básico, me voy a remitir muchas veces en este texto a este otro tutorial:

tutorial-bot-mastodon.gmi

Recursos necesarios

Igual que mis otros bots, los voy a hacer en Python, que es el lenguaje en el que llevo trabajando muchos años y me siento más confiado.

Mastodon

Como interfaz con Mastodon, seguiré usando la librería Mastodon.py. Los detalles de establecimiento de conexión y credenciales no voy a repetirlos, y me remito al otro tutorial.

https://github.com/halcy/Mastodon.py

En esta ocasión el bot va a ser muy diferente, porque al ser interactivo no basta con despertarlo a intervalos con un cron, sino que hay que mantener una conexión permanente con el servidor Mastodon para que este nos vaya notificando los mensajes que vayan llegando. Es lo que en el argot se llama "streaming".

Base de datos

En este bot vamos a tener que guardar miles de ejercicios junto a los resultados de todos los intentos de los usuarios y su puntuaciones. Así pues, no nos vale simplemente guardar lo datos en un archivo JSON como hicimos en otros casos. Necesitamos una base de datos que nos gestione toda esa información.

Por suerte, las librerías estándar de Python traen de serie una estupenda implementación de SQLite, ideal para proyectos de pequeño tamaño como este.

https://www.sqlite.org/

SQLite es una base de datos SQL relacional clásica. Llevo 30 años trabajando con SQL, por lo que puedo decir que esta parte ha sido coser y cantar.

No es mi intención hacer un tutorial de SQL, así que no entraré en muchos detalles. Creo que las sentencias que se van a usar son sencillas y se entienden bastante bien. Es un lenguaje que se inventó para poder leerlo como lenguaje natural, y así es.

Ajedrez

Vamos a necesitar reconocer movimientos de ajedrez en lenguaje algebraico y, sobre todo, pintar el tablero para ilustrar los ejercicios. Por suerte, hay una librería muy bien hecha y establecida para estas tareas.

Documentación de python-chess

No necesitaremos un motor de juego, porque los ejercicios los tenemos ya resueltos y no hay que "inventarse" nada. La librería nos servirá principalmente para dos tareas:

- Generar las imágenes de los tableros con las posiciones de los ejercicios.

- Convertir los movimientos entre distintos tipos de notación.

Para pintar el tablero usaremos el paquete SVG que viene incluido con la librería chess. Esto generará una imagen en formato vectorial a partir de la notación FEN de un tablero. (Ver más adelante para conocer los tipos de notación).

Dado que Mastodon no soporta imágenes en SVG, recurriremos a una librería gráfica para convertir a PNG, aunque esto se sale de este tutorial y no voy a entrar en detalles. Solo decir que he optado por una combinación de ReportLab y Cairo, que son dos librerías muy extendidas de manejo de formatos gráficos. Es solo una entre las muchas opciones que podría haber tomado, y que no hubieran cambiado mucho el resultado.

En cuanto a la interpretación de los movimientos, necesitamos, a partir de un FEN de un tablero y un movimiento UCI o PGN, obtener el FEN del tablero modificado después de dicho movimiento, contando con todos los cálculos para que el movimiento sea legal. Con esto, iremos generando paso a paso las imágenes para cada movimiento del ejercicio. Esto encaja perfectamente con las capacidades de python-chess, la cual también servirá para traducir los movimientos PNG a UCI y viceversa.

Notación

Para el desarrollo de este bot va a ser muy necesario conocer los distintos tipos de notación de ajedrez, y el formato en el que Lichess proporciona los ejercicios.

FEN (Forsyth-Edwards Notation)

El estándar más extendido para representar un tablero de ajedrez algebraicamente es la notación FEN. Un tablero se representa con una cadena de caracteres con ciertos campos separados por espacios. Por ejemplo:

`6k1/p4p2/6p1/1KP5/P7/1n1r1P2/6PP/7R b - - 0 32`

- Posición de las piezas: Las filas se separan con barras `/`, los números representan casillas vacías y las letras son las iniciales en inglés de cada pieza (en minúsculas las negras, en mayúsculas las blancas). La convención es usar la K para el rey (King) y la N para el caballo (Knight). En el ejemplo, la primera fila del tablero son seis casillas vacías, el rey negro y una casilla vacía.

- Turno: `w` si les toca mover a las blancas, `b`si son las negras.

- Disponibilidad de enroque: Los enroques que estén disponibles aparecen con `K`, `Q`, `k`, `q`según sean por flanco de rey o de dama, blanco o negro, respectivamente. Se pone un guión si no es aplicable.

- Disponibilidad de captura al paso: Se indican las coordenadas de la casilla en la que está disponible la captura al paso. Guión si no es aplicable.

- Número de movimientos desde que se capturó una pieza o se movió un peón: Esto sirve para calcular las tablas en ciertos casos (cero en el ejemplo).

- Número total de movimientos hasta el momento (por parejas blanco/negro). En el ejemplo, llevamos 32 pares de movimientos.

Con todo esto, el FEN anterior corresponde a este tablero (hemos orientado el tablero invertido para representar que les toca mover a las negras):

img/chess/20241105123353.png

PGN (Portable Game Notation)

Para la notación de los movimientos, lo más común en ajedrez (por ordenador, o incluso a mano) es representar algebraicamente la pieza a mover (inicial mayúscula del nombre en inglés) y la casilla de destino del movimiento (por ejemplo, `Nd4`, caballo a d4). En el caso de los peones, la inicial de la pieza se omite, y solo se pone la casilla de destino (`a6`, peón a a6).

A esto se le llama SAN (Short Algebraic Notation), que es parte de la PGN (Portable Game Notation).

Por ejemplo, en el tablero anterior, las negras podrían mover el rey a la casilla h8, lo cual se representa con `Kh8` . Hay variantes con las iniciales en distintos idiomas (por ejemplo, en español sería `Rh8`), pero en el juego por ordenador se suele usar el inglés, y eso es lo que vamos a hacer nosotros.

Se usan otros símbolos para ciertas circunstancias del juego:

- Se añade un signo más (`+`) si el movimiento pone en jaque al rey rival. En nuestro ejemplo, sería `Nd4+` para indicar que el caballo se ha movido a d4, donde amenaza al rey blanco.

- Se añade una almohadilla (`#`) para indicar jaque mate.

- Se usa una equis (`x`) para indicar una captura. En el ejemplo, `Rxf3` indicaría que la torre (Rook) se ha "comido" el peón de f3.

- El enroque corto se nota como `O-O`y el largo como `O-O-O` (usando siempre letras O mayúsculas y nunca ceros).

- La promoción de un peón se indica con el símbolo igual (`=`) seguido de la pieza que se obtiene. Por ejemplo, `g8=Q` indica que un peón ha llegado a g8 y ha promocionado a dama.

- En el caso se ambigüedad en el movimiento (suele pasar con torres y caballos), se añaden las coordenadas necesarias para que no haya duda. Por ejemplo, si hay dos torres que pueden llegar a la casilla b3, usaremos `Rab3` para indicar que se ha movido la que estaba en la columna a.

UCI (Universal Chess Interface)

La notación PGN es muy descriptiva para la interfaz máquina-humano o humano-humano, pero para la comunicación máquina-máquina se usa normalmente otra notación más concisa y fácil de procesar.

En esta notación, cada movimiento se anota simplemente emparejando las coordenadas de origen y las de destino, sin indicar la pieza, los jaques ni otras complicaciones. La simplicidad facilita el tratamiento automático, sin tener que programar tantas reglas y ambigüedades.

Por ejemplo, para mover el rey de g8 a h8 lo anotamos simplemente como `g8h8`.

Los enroques se anotan solo con el movimiento del rey. Por ejemplo, `e1g1` sería un enroque corto del blanco. El movimiento de la torre en el enroque se da por entendido sin nombrarlo.

Las promociones se indican simplemente añadiendo la inicial de la pieza promocionada en minúsculas. Por ejemplo, `g7g8q` significa que un peón se ha movido de g7 a g8 y ha promocionado a dama.

En nuestro programa, usaremos la notación UCI internamente para todos los movimientos, dado que la base de datos de Lichess está en ese formato. Sin embargo, al usuario le permitiremos usar UCI o PGN indistintamente, dado que esta última es la más popular entre los jugadores.

Preparación de la base de datos

El archivo de ejercicios, así como la descripción del formato y contenido de encuentran en esta página:

lichess.org - Open database

Los ejercicios vienen en un gigantesco archivo CSV comprimido. Para cargar todo eso en la base de datos y crear las tablas necesarias, he preparado un script específico: `dbsetup.py`

Se llama al script indicando el archivo de origen y el de destino (que no debe existir previamente). El tercer argumento indica el mínimo de "popularidad" de los archivos a cargar. Esto lo he incorporado como una forma de filtrar y así reducir el tamaño final de la base de datos, que podría afectar al rendimiento.

python3 dbsetup.py lichess_db_puzzle.csv db.sqlite 95

El archivo CSV hay que proporcionarlo descomprimido. Para ello, antes hay que utilizar alguna utilidad como, por ejemplo, unzstd.

La ejecución tarda bastantes minutos. Para que la base de datos se mantenga en un tamaño manejable, en este ejemplo he puesto un límite para cargar solo los ejercicios de popularidad superior a 95, que deben ser los mejores. Aún con tanta restricción, se cargan más de 800000 ejercicios (de sobra para mis pretensiones) y ocupa 125 MB de tamaño.

Todas las tablas de la base de datos se crean en el mismo script. Son:

- PUZZLES: El almacén de los ejercicios. Se incluye el ID asignado por Lichess, el tablero en formato FEN, los movimientos en formato UCI, la valoración y las etiquetas temáticas.

- PLAYED: Registro de los ejercicios que se han jugado privadamente o publicado como "puzzle del día". Incluye el nombre de usuario, para los ejercicios privados. El mismo ejercicio se puede repetir para otro usuario, pero no así los públicos.

- MOVES: Registro de los movimientos intentados por los usuarios. Tiene una relación de *foreign key* con PLAYED.

- COMPLETED: Registro de los ejercicios completados con éxito por los usuarios (sin fallos). Esto sirve para hacer la lista de los "mejores" jugadores.

- USERS: De cada usuario se guarda su nick y sus preferencias de privacidad y dificultad.

Streaming

Para hacer un bot interactivo, hay que entender como funciona el mecanismo de notificaciones.

Mastodon tiene varios tipos de *streams* (o canales) a través de los cuales se pueden recibir datos sobre eventos. Hay canales para eventos públicos (no relacionados con un usuario concreto) y privados.

En el caso del *stream* de usuario, puede recibir las siguientes notificaciones:

- "mention": Has sido mencionado en un mensaje.

- "reblog": Un mensaje propio ha sido impulsado.

- "favourite": Un mensaje propio se ha marcado como favorito

- "follow": Un usuario te sigue.

- "poll": Se ha completado una encuesta en la que participaste.

- "follow_request": Un usuario ha solicitado seguirte.

En este caso usaremos el stream de usuario solo para recibir las menciones ("mention"), que son el medio por el que los usuarios nos enviarán los comandos y movimientos. El resto de notificaciones las ignoraremos.

Al abrir un stream, se crea una conexión permanente con el servidor, y a esa conexión le enganchamos un "listener", que es el programa que va a recibir y gestionar las notificaciones.

El listener debe ser subclase de una clase especifica, y hay que pasarle al stream una instancia de dicha clase.

Así pues, el listener más sencillo sería algo así:

class ChessbotListener(StreamListener):  
    def on_notification(self, status):  
        print(status)  
  
  
mastodon.stream_user(ChessbotListener())

He omitido los detalles de autenticación (puedes verlos en el otro tutorial) y el ejemplo comienza con la instancia "mastodon" (que representa a la sesión de usuario) ya activa.

Este programa se queda eternamente corriendo y va a mostrar por pantalla todas las menciones que reciba la cuenta a la que está conectado.

Lo que se recibe con la mención (que he llamado status) es un objeto con muchísima información que nos servirá para procesar el evento. Los detalles de todas las estructuras de datos que se reciben se pueden consultar en esta página de documentación:

https://mastodonpy.readthedocs.io/en/stable/02_return_values.html

Reconexión asíncrona

Esta aproximación es bastante tosca, porque en el momento que se corte la conexión (algo que puede pasar en cualquier momento y es bastante común) el programa terminará y tendremos que reiniciarlo a mano. En cualquier aplicación de red, esperar que una conexión con un servidor pueda mantenerse abierta indefinidamente sin cortes es poco menos que una quimera.

Para evitar eso, hay que activar los flags `run_async` y `reconnect_async`. Esto hace que el listener funcione en segundo plano en otro hilo de ejecución y, lo que es más importante, se reconecte cada vez que pierda la conexión.

Un efecto colateral de eso es que, al estar el listener en segundo plano, ahora el programa no se queda esperando y termina inmediatamente. Así que le añadimos un bucle con sleep para que se quede activo indefinidamente. Como ventaja adicional, cada vez que termina un sleep podemos realizar operaciones rutinarias como si de un cron se tratase.

handler = mastodon.stream_user(ChessbotListener(), run_async=True, reconnect_async=True)  
while True:  
    time.sleep(600)  
    if handler.is_alive():  
        logger.info('Ejecutando limpieza')  
    else:  
        logger.warning('El handler ha muerto. Saliendo.')  
        sys.exit(2)

El tratamiento que doy en este ejemplo es demasiado simple, y lo único que hace es rendirse cobardemente cuando encuentra que el *handler*, es decir, el objeto que mantiene el hilo de ejecución en segundo plano, ha muerto.

En la versión que puedes encontrar en el código definitivo del bot, encontrarás que hago dos bucles anidados, uno de ellos va comprobando el estado del handler cada 60 segundos y el otro se encarga de recrearlo cada vez que muere. Como remate, voy contando las vueltas del bucle para, una vez pasados 60 minutos, hacer ciertas labores, como enviar el "ejercicio del día", los desafíos y recuentos de estadísticas.

Reconociendo comandos

Es necesario, pata que el funcionamiento del bot sea lo más flexible posible, que reconozca ciertos comandos. Por ejemplo, "ayuda" para obtener instrucciones de uso, o "nuevo" para obtener un nuevo ejercicio.

Mi intención es que el bot sea bilingüe (español/inglés), con la posibilidad de añadir más lenguajes si fuera necesario. En principio, por el idioma del comando puedo suponer el lenguaje que desea usar cada usuario. Para ello bastará con asegurarme de que una misma palabra no coincida para varios lenguajes. Por otra parte, entre la información que se recibe en la notificación está también el código de lenguaje usado por el usuario.

El soporte multilenguaje generalmente se hace teniendo un archivo de etiquetas por cada lenguaje soportado, y usando el inglés como idioma por defecto.

He creado una carpeta "lang" y dos archivos: es.yml y en.yml, donde estarán todas las etiquetas traducidas a cada lenguaje. La razón por la que he usado YAML como lenguaje de marcado es que soporta multilíneas (cosa que no hace JSON). En este proyecto va a hacer falta bastantes mensajes multilínea. Otra ventaja es que YAML es un formato más *humano* y fácil de editar.

Puedes encontrar una buena introducción a YAML en Wikipedia:

https://es.wikipedia.org/wiki/YAML

Una de las entradas en los archivos de etiquetas será "commands" donde iré colocando cada comando y su equivalente en el idioma seleccionado. Luego, haré un diccionario indexando cada palabra con su comando equivalente (usando el inglés como base) y el idioma al que pertenece.

Los archivos YAML de cada lenguaje, además de uno genérico `texts.yml`, se cargan al arranque en unas estructuras de diccionarios:

# Load text files  
with open('texts.yml', encoding='utf-8') as f:  
    texts = yaml.safe_load(f)  
lang = {}  
for file in os.listdir('lang'):  
    if file.endswith('.yml'):  
        with open(os.path.join('lang', file), encoding='utf-8') as f:  
            lang[os.path.splitext(file)[0]] = yaml.safe_load(f)

Usaremos una sencilla función de utilidad para "traducir" cada etiqueta en su correspondiente en el lenguaje escogido, o en inglés por defecto.

def translate(language, string):  
    if language in lang:  
        return lang[language]['messages'][string]  
    else:  
        return lang['en']['messages'][string]

Reconociendo movimientos

Aparte de los comandos, el bot puede recibir movimientos para resolver los ejercicios. Esta es la parte fundamental del trabajo, y a lo que dedicaremos más líneas de código. Aceptaremos formatos UCI (porque es el que tienen los ejercicios de Lichess en origen) y PGN (porque es el más popular entre los jugadores).

Como la variedad de movimientos en ambos formatos es inmensa, usaremos expresiones regulares para identificarlos, y de esta manera distinguirlos de otros comandos, palabras o lo que sea que recibamos dentro de una mención.

Yo siempre uso la página `regex101.com`, que resulta muy cómoda para ir *jugueteando* con las expresiones y ver si coinciden con las cadenas de ejemplo que deseamos.

https://regex101.com/

Si no conoces lo que son las expresiones regulares ni las has visto nunca, es inútil que te enlace una introducción al tema, porque suele ser necesario bastante entrenamiento para manejarlas. Simplemente, creete que estos largos "churros" de caracteres son los patrones que identifican si una cadena de caracteres cualquiera es un movimiento UCI o PGN o ninguno de los dos:

UCI:

[a-h][1-8][a-h][1-8][rnbq]?

PGN:

([RNBQK]?[a-h]?[1-8]?x?[a-h][1-8](=[RNBQ])?[+#]?)|(O-O(-O)?[+#]?)

En el caso de UCI, la expresión es trivial, por su sencillez. Sin embargo, la PGN tiene muchas variantes y es imposible con una sola expresión regular saber con exactitud si es un movimiento legal. Como no estamos jugando partidas reales y finalmente los resultados ya nos los proporciona Lichess, lo peor que puede pasar es que un usuario envíe un PGN ilegal que llegue a colar, simplemente será detectado después cuando se lo pasemos a la librería python-chess para verificar los movimientos.

Con todo esto, el parseo de los post recibidos queda de esta manera:

for chunk in text.split():  
    c = chunk.lower()  
    if is_uci_move(c):  
        uci_move = c  
        pgn_move = None  
    elif is_pgn_move(chunk):  
        # PGN only supports case-sensitive  
        pgn_move = chunk  
        uci_move = None  
    elif c in commands:  
        command, language = commands[c]  
    elif c in [t.lower() for t in all_tags]:  
        tag = c

Lo que hemos hecho es trocear el texto en palabras sueltas e ir comprobando para cada una si coincide con un movimiento, un comando o una etiqueta. Estamos simplificando bastante, lo suficiente para no complicarnos creando un auténtico *parser* de un lenguaje, pero esto permite al usuario enviar varios movimientos y comandos a la vez.

Para evitar esto, al final usamos el OR exclusivo (^) para asegurarnos de que solo se usa una y solo una modalidad a la vez. Este es un operador lógico poco usado en la mayoría de programas, pero en este caso nos viene como anillo al dedo:

if not ((uci_move is not None) ^ (pgn_move is not None) ^ (command is not None) ^ (tag is not None)):  
    # Only one mode allowed at a time  
    err = translate(language, 'too_many_commands')

Siguiendo el hilo

Uno de los problemas más interesantes que ha surgido es la necesidad de, una vez recibido un comando o movimiento, identificar a qué ejercicio corresponde. No vale simplemente viendo lo último que ha jugado el usuario, porque éste podría haber abandonado un ejercicio y luego empezar otro.

Esto me tuvo bastante tiempo rumiando cómo se podría hacer, y finalmente quedó muy bonito para mi gusto. Aproveché que en Mastodon cada mensaje (llamado *status* en la jerga) tiene un identificador único (dentro del mismo servidor, se entiende), y que cada respuesta lleva un campo `in_reply_to_id` que dice a qué mensaje está respondiendo.

Esto significa que puedo ir explorando el "hilo" de mensajes recursivamente, hasta encontrar aquel en el que el bot publicó el ejercicio o una continuación del mismo. En las tablas PLAYED y MOVES de la base de datos voy guardando una columna con los `status_id` de los mensajes en los que esos puzzles o esos movimientos se han publicado.

La función recursiva que hace la búsqueda es:

def get_puzzle_played_so_far(reply_id, user):  
    if reply_id is not None:  
        current_id = reply_id  
        played = find_played(current_id, user)  
        while played is None:  
            # Try reply by reply  
            replied = mastodon.status(current_id)  
            if replied.in_reply_to_id is None:  
                break  
            else:  
                current_id = replied.in_reply_to_id  
                played = find_played(current_id, user)  
        return played  
    else:  
        return None

Usamos una pseudo-recursión iterativa, buscando en el servidor cada `status_id` a partir del `in_reply_to_id` anterior, hasta el momento en que la función de utilidad `find_played` nos encuentra la entrada en la base de datos o bien llegamos al final sin éxito.

La función `find_played` es una entre muchas utilidades de operaciones con la base de datos que he agrupado en un módulo `dbutils.py`. Es un buen ejemplo de cómo funcionan las llamadas a SQLite:

def find_played(status_id, user):  
    with sqlite3.connect(os.getenv('SQLITE_DB')) as con:  
        cur = con.cursor()  
        cur.execute('''  
            SELECT puzzle_id
            FROM played           
            WHERE status_id = ?            
            AND (user = ? OR daily = 1)        
        ''', (str(status_id), user, ))  
        res = cur.fetchone()  
        if res is not None:  
            cur.execute('''  
                SELECT id, fen, moves, rating, tags, 0, 
                substr(moves, 1, instr(moves, ' ') - 1), 0
                FROM puzzles
                WHERE id = ?
            ''', res)  
        else:  
            cur.execute('''  
                SELECT p.id, p.fen, p.moves, p.rating, p.tags, 
                m.order_num, m.moves_so_far, m.completed                
                FROM moves m
                JOIN puzzles p ON m.puzzle_id = p.id
                WHERE status_id = ?
                AND user = ?
                ORDER BY m.order_num DESC LIMIT 1            
            ''', (status_id, user, ))  
        return cur.fetchone()

Esta función, además de localizar el ejercicio en curso, también extrae toda la información que vamos a necesitar para aplicar el movimiento, comprobar si es correcto y generar la imagen del nuevo tablero.

Aplicando movimientos

Por último, voy a explicar someramente cómo se utiliza la librería python-chess para aplicar movimientos y generar el tablero resultante.

Cada ejercicio de Lichess nos proporciona la posición inicial en notación FEN y la secuencia de movimientos en UCI, teniendo en cuenta que el primer movimiento que aparece es el último del rival antes de empezar el ejercicio propiamente dicho.

Voy a tomar como ejemplo el siguiente ejercicio:

https://lichess.org/es/training/VzaDm

En la base de datos, Lichess nos proporciona lo siguiente:

- FEN: `r3rbk1/ppq2ppp/8/nQ6/3P4/P1P2N2/5PPP/R1B2RK1 b - - 0 19`

- Movimientos: `c7c3 c1d2 c3c4 b5a5`

Para obtener el tablero inicial del ejercicio, tengo que cargar un tablero con la posición FEN y aplicarle el primer movimiento (`c7c3`). Por último, sacaré la imagen gráfica del tablero, resaltando dicho último movimiento.

Para cargar la posición inicial, solo tenemos que crear un objeto Board:

board = chess.Board('r3rbk1/ppq2ppp/8/nQ6/3P4/P1P2N2/5PPP/R1B2RK1 b - - 0 19')

Ahora aplicamos el primer movimiento:

move = board.push_uci('c7c3')

El tablero `board` ahora está en la posición después de aplicar el movimiento, y hemos obtenido un objeto `move` que representa dicho movimiento.

Solo nos queda obtener el gráfico SVG del tablero indicando el último movimiento:

svg = chess.svg.board(board, lastmove=move)

La conversión a PNG, como dije, no la voy a tratar. El resultado es este:

img/chess/6ded046bd202e3fe.png

Hay que tener en cuenta quién es el siguiente en mover, para así rotar el tablero como cortesía hacia nuestro usuario si va con negras. La propiedad `turn` del objeto `board` nos lo indica mediante unas constantes `chess.BLACK` y `chess.WHITE`. Así que podemos colocar el tablero en el sentido adecuado de esta manera:

svg = chess.svg.board(board, orientation=board.turn, lastmove=move)

En el ejemplo no hay efecto, dado que la orientación por defecto ya era para blancas.

Con un poco de ingeniería pythonesca se puede generalizar a cualquier secuencia de movimientos, obteniendo el último estado del tablero:

def get_board_image(fen, moves):  
    board = chess.Board(fen)  
    first_turn = board.turn  
    last = None  
    for m in moves:  
        last = board.push_uci(m)  
    return chess.svg.board(board, orientation=chess.BLACK if first_turn == chess.WHITE else chess.WHITE, lastmove=last)

La lista de movimientos `moves` la preparo para que sea una lista de cadenas, resultado de hacer un `split()` a los movimientos originales.

De PGN a UCI

Por último, voy a mostrar cómo usar esta librería para convertir notación PGN a UCI, lo cual es necesario para nuestro bot, porque la mayoría de los aficionados están acostumbrados a usar PGN, pero la base de datos de Lichess está en UCI.

Una particularidad de PGN (o de SAN en general) es que es necesario conocer la posición de todas las piezas del tablero para interpretarlo inequívocamente. Si decimos, así a secas, Ng7, sabemos que un caballo se ha movido a g7, pero no sabemos dónde estaba colocado antes, ni si el movimiento es posible porque otras piezas lo impidan. Así pues, un movimiento PGN requiere primero cargar el tablero y después aplicar el movimiento. Solo entonces, podremos "entender" el movimiento y convertirlo a UCI.

Para ello, crearemos el objeto `Board` igual que antes, y ahora en lugar de `push_uci()` le aplicaremos `push_san()`. Usaremos el objeto `Move` obtenido para sacar su equivalente UCI

def pgn2uci(fen, pgn):
    board = chess.Board(fen)
    try:
        move = board.push_san(pgn)
        return move.uci()
    except ValueError:
        return None

La librería emite excepciones (todas subclases de `ValueError`) cuando encuentra que no puede aplicar el movimiento por diversas razones. En ese caso, desistimos y devolvemos `None`.

Conclusión

Espero que este tutorial te haya sido de ayuda. Yo me lo he pasado muy bien programando este bot, y he aprendido muchas cosas que antes desconocía.

En el enlace de Codeberg del principio tienes el código completo y detalladas instrucciones (en inglés) para instalar y poner a correr el bot, si te apetece. Es licencia GPLv2. Si te apetece jugar con la instancia que yo mantengo, la tienes en @ChessPuzzleBot@masto.es

@ElPamplina@masto.es

elpamplinadecai@gmail.com

Volver al índice