Notxor tiene un blog

Defenestrando la vida

Programando un tablero de ajedrez que cumpla las reglas

Notxor
2023-04-17

Llevo un tiempo sin contar nada en el blog y ya me estaba generando estrés el pensamiento recurrente de «debería escribir algo». Así, sin tema concreto, sólo puedo utilizar lo que he estado haciendo todo este tiempo. Que si bien no es sólo mérito mío1, me ha tenido absorbido y concentrado todo este tiempo sin procrastinar en otra cosa que no sea ésta. Ya comenté en el último artículo lo de hacer un programa de ajedrez2, pues bien: lo estamos haciendo, no sólo en una, sino en tres formas de visualización o interfaces y el trabajo está siendo muy instructivo. Cuando digo interfaces me refiero a representación del tablero en pantalla y, efectivamente, estamos desarrollando tres: 1. Modo texto desde el terminal. 2. Mediante la librería gráfica fltk y 3. Mediante la librería gráfica imgui.

Primero veamos cómo pintan los distintos tableros:

Captura_no-gui.png
Figura 1: Partida desde línea de comandos
Captura_fltk.png
Figura 2: Partida en una ventana fltk
Captura_imgui.png
Figura 3: Partida en una ventana imgui

Diferencias entre tableros:

Al conectar el motor de juego, con el protocolo UCI, nos dimos cuenta que con ese protocolo, no comprueba nada ni tiene seguimiento de la partida. Simplemente le pasas una determinada posición del tablero mediante notación FEN y te devuelve la mejor jugada que pueda calcular en base a ese estado. Así pues, el mantenerse dentro de las reglas del ajedrez corre del lado del programa cliente. Es decir, El motor si encuentra algo erróneo o absurdo se queda esperando una posición «calculable» sin decir nada y por tanto el programa cliente no puede saber si no contesta porque ha habido un error o porque está calculando.

jugada.svg
Figura 4: Comunicación entre elementos del programa

En la figura anterior, el proceso Partida debe controlar el proceso de juego asegurándose de que los distintos movimientos se ajustan a las normas del ajedrez. Cuando es así, su estado cambia, luego veremos cómo. El proceso Interface es el encargado de comunicar la Partida con el Motor de juego, enviando jugadas, y con el usuario, mostrando el tablero. El proceso Motor únicamente recibe una posición del estado de la partida y emite la jugada que considera más oportuna. El motor stockfish permite varios parámetros de configuración, incluyendo nivel de profundidad de cálculo o el ajuste de ELO para controlar la dificultad contra la que queremos enfrentarnos. Por ejemplo, también se puede configurar si queremos que el motor se mantenga ocioso mientras nosotros movemos o no. En mi caso lo tengo configurado a un nivel de profundidad 10 (como limitación para acelerar un poco su respuesta) y sin dejarlo pensar mientras muevo. Aún así me gana.

Partida

Muestro aquí los objetos que intervienen en la partida para no hablar en vacío, pero tampoco voy a hacer una disertación detallada de todos los detalles:

type Tablero* = ref array[64, char]

type
  Movimiento* = object
    san*: string
    fen*: string

type
  Partida* = ref object
    tablero*: Tablero
    movimientos*: seq[Movimiento]
    turno*: string
    enroqueCortoBlancas*: bool
    enroqueLargoBlancas*: bool
    enroqueCortoNegras*: bool
    enroqueLargoNegras*: bool
    posAlPaso*: string
    contador*: int
    numJugada*: int

El Tablero es un array de 64 caracteres, cada uno de los cuales representa el contenido de una casilla. La Partida cuenta con un tablero que es el que utilizan las interfaces para mostrarlo por pantalla. Además, la Partida cuenta con una lista de movimientos. Cada Movimiento consta de una cadena SAN4 y el FEN que muestra el estado de la partida tras ese movimiento. El movimiento 0 es el estado inicial del tablero al comenzar la partida. De momento, sólo es posible el inicio estándar, pero esperamos poder iniciar las partidas de otros modos, como el ajedrez 9605, o cuando ambos jugadores pactan una ventaja, por ejemplo iniciando el juego uno de ellos con una figura menos.

El meollo de la partida la controla muevePieza. Dicha función controla que al realizar un movimiento se siguen las normas del ajedrez llamando a la función esMovimientoLegal, y si, efectivamente el movimiento es legal, realiza el movimiento, genera la cadena SAN y la cadena FEN, las guarda y pone la Partida en el estado correspondiente. Aunque aún faltan un par de detalles para dar por finalizada la implementación de Partida. El código, de momento, tiene esta forma:

##[
La función `muevePieza` cambia la posición de una pieza desde una
casilla de origen `co` a una casilla final `cf`, en el tablero `p`
dado.

Devuelve `true` si se efectúa el movimiento de pieza y `false` en
caso contrario. Cuando se realiza el movimiento de pieza también
cambia el turno en la partida.
]##
# TODO: Falta por implementar:
#       1. la promoción de los peones: de momento sólo promocionan a reina
#       2. detección del Mate
proc muevePieza*(p: Partida, co, cf: string): bool =
  var
    san: string
    [...]
  if p.esMovimientoLegal(t, co, cf):

    [...]

    # hace el movimiento
    t.ponValorCasilla(cf, figura)
    t.ponValorCasilla(co, VACIA)
    if san == "":  # Si ha habido enroque ya llega aquí relleno
      # si no, junta las partes de la notación del movimiento
      san = fig & origen & captura & cf & jaque & ep
    # Cambia el jugador de turno
    p.cambiaTurno()
    # Guarda el registro del movimiento
    p.movimientos.add(Movimiento(san: san, fen: p.fen()))
    return true
  else:
    return false

Algunas cosas merecen un poco más de atención, como los detalles sobre movimientos de peones, reyes y torres. Estas tres piezas producen cambios en el estado de Partida por reglas especiales que les afectan, como la captura al paso6 de los peones, la regla de los cincuenta movimientos7, de los que Partida lleva un contador, o de las reglas de enroque8, que también debe vigilar Partida y que se ven afectadas por movimientos de reyes y torres. Además, en el enroque se debe mover también la torre y no sólo el rey y, por último, debe registrar los datos para generar la cadena san que es la notación algebraica del movimiento.

Sin embargo, el mayor peso de proceso y comprobaciones recae sobre la función esMovimientoLegal:

##[
Para que un movimiento sea legal, en una partida `p` la pieza situada
en la casilla de origen `co` debe estar en su turno, no puede mover al
mismo sitio donde está y la casilla de destino `cf` debe ser
alcanzable según las normas del ajedrez.
]##
proc esMovimientoLegal(p: Partida, t: Tablero, co, cf: string): bool =
  var
    figura: char = t.contenidoCasilla(co)
    tablero: Tablero

  if figura != VACIA and estaEnTurno(p, figura) and co != cf and p.esDestinoLegal(t, co, cf):
    # Si el movimiento parece legal, realizamos el movimiento en un tablero aparte
    tablero = t.copiarTablero()
    tablero.ponValorCasilla(co, VACIA)
    tablero.ponValorCasilla(cf, figura)
    # Y comprobamos que no queda el rey propio en jaque
    if tablero.estaEnJaque(p, figura.colorPieza()):
      echo("Deja el rey propio en jaque.")
      return false
    return true
  else:
    return false

Las comprobaciones que hace esta función son las siguientes:

  1. Que para la casilla de origen no se ha seleccionado una casilla vacía.
  2. Que la figura de la casilla de origen está en su turno.
  3. Que la casilla de origen y destino son distintas.
  4. Que la figura de la casilla de origen puede mover a la casilla final. La función esDestinoLegal comprueba las normas de movimiento de cada pieza y determina si puede alcanzar la casilla final.
  5. Que al realizar el movimiento no quedaría el rey propio en jaque. Para hacerlo, copia el tablero de la partida en otro, poniéndolo en la situación de que el movimiento se ha realizado y comprueba que el rey de su color no quede en jaque.

Falta por implementar un par de detalles que aún no tengo claro cómo se pueden afrontar, aunque sí alguna idea:

  • Promoción del peón: Cuando un peón alcanza la fila más alejada de su posición de inicio puede promocionar a cualquier figura, excepto a rey. Puede convertirse en reina, torre, alfil o caballo a elección del jugador. Lo habitual es que todos elijamos la reina por ser la pieza más potente, pero puede elegirse otra, por ejemplo, para evitar ahogar al rey contrario y acabar en tablas o porque estratégicamente le guste más al jugador. En la notación por coordenadas se anota la pieza deseada después del movimiento, por ejemplo: e7e8N coronaría el peón y lo convertiría en un caballo. En modo gráfico podría ser mostrar un diálogo para que elija el jugador y en modo texto, añadir la pieza deseada a las coordenadas al introducir el movimiento para coronar el peón... pero aún hay que hacerlo.
  • Detección del mate: Actualmente Partida no sabe si ha acabado el juego porque no se pueda evitar la captura de un rey. Mirando por Internet he visto un algoritmo que tiene dos casos, a partir de un jaque inicial:
    1. El jaque inicial se da por dos piezas a la vez:
      1. Comprobar si algún movimiento de rey evita el jaque.
    2. El jaque inicial lo da una sola pieza:
      1. Comprobar si la pieza que da el jaque puede ser capturada.
      2. Comprobar si se puede interponer una pieza propia entre el rey y la pieza atacante.
      3. Comprobar si algún movimiento del rey evita el jaque.

Poder analizar partidas implica navegarlas, poder avanzar y retroceder tantas veces como sea necesario. Para colocar la partida en una posición cualquiera, la aproximación por fuerza bruta de iniciarla e ir haciendo todos los movimientos anteriores. Sin embargo, creo que esta forma es un poco primitiva y que la mejor manera sería utilizar la notación FEN. Por esto, tras cada movimiento se registra no sólo la notación estándar, sino también el estado de la partida con FEN.

Notación FEN

La forma de comunicar al motor de juego la situación actual de la partida es con la notación FEN. Ya lo expliqué en el artículo anterior: consta de seis campos:

  • Cadena representando las 8 filas del tablero:
    • El tablero se lee de izquierda a derecha comenzando por la casilla a8 y de arriba hacia abajo hasta h1.
    • Cada fila se separa de la siguiente con el signo /.
    • Se utiliza la notación de piezas con la inicial inglesa, en mayúscula para blancas y en minúscula para negras.
    • Las casillas vacías se muestran agrupándolas con un número.
  • Indicador de color activo, o a quién le toca mover, w (white) y b (black).
  • Indicador de posibilidad de enroques: QKqk (de Queen y King) que actuarían como flags de que blancas (en mayúscula) y negras (en minúscula) tienen capacidad de enrocar aún por alguno de los lados o un - si ningún jugador tiene ya posibilidad de enroque.
  • Casilla de destino para la captura al paso. Si no hay casilla de destino aparecerá un -. La casilla destino aparecerá después de un doble avance de salida de un peón. Un peón contrario que se encuentre en la fila 3 (para negras) o en la fila 6 (para blancas) puede ejercer la acción de captura al paso, situándose en la casilla donde hubiera terminado si el movimiento del peón que avanza hubiera sido de una sola casilla.
  • Reloj de medios movimientos: Este número es el recuento de medios movimientos desde el avance de peones o movimientos de captura y se utiliza para la regla de conteo de 50 movimientos. Esta regla establece tablas o empate, si se producen 50 movimientos seguidos sin que se mueva un peón o se realice alguna captura.
  • Número de jugadas completas: Comienza puesto a 1 y se incrementa tras cada movimiento de las negras.

Todos los campos llegan en una sola cadena separados por espacios.

En base a los campos anteriores se establece el estado de la Partida con la función:

##[
Esta función `fen` recibe una cadena codificada con la notación FEN y
la utiliza para situar la partida `p` en el estado descrito por la
misma, decodificando los campos que esta notación proporciona.
]##

proc fen*(p: Partida, fen: string) =
  let campos = split(fen)
  p.limpiarTablero()
  p.ponPiezasFen(campos[0])
  p.turno = campos[1]
  p.ponEnroquesFen(campos[2])
  p.posAlPaso = campos[3]
  p.contador = parseInt(campos[4])
  p.numJugada = parseInt(campos[5])

También hay una función que realiza el proceso inverso y devuelve una cadena codificada como FEN a partir del estado de una partida:

proc fen*(p: Partida): string

Conclusiones

No quiero cansinar mucho con el proyecto, aún está en pañales aunque promete maneras. De momento tenemos un objeto que nos permite mantener una partida de ajedrez dentro de las normas de juego, con alguna carencia, que sin duda para cuando se publique este artículo ya estará solucionada o en vías de solución. Además se puede conectar con un motor de juego. De momento sólo se ha probado con Stockfish mediante el protocolo UCI, aunque está pensado admitir otros motores como GNU chess o Crafty. Quizá también soportar algún protocolo más, como XBoard.

Lo que estoy ahora mirando es poder configurar el motor para jugar desde la interface. También introducir otros parámetros del programa, que me permitan o bien jugar o bien analizar una partida desde el primer movimiento al último realizado.

El siguiente paso es poder cargar archivos PGN, con múltiples partidas, que permita analizar su contenido, desarrollar las partidas y hacer variaciones sobre ellas utilizando el motor de juego, etc.

También el asunto del reloj y el tiempo de juego está en pendientes, pero no creo que sea el momento aún para afinar tanto. Primero hay que terminar con la representación del tablero, asegurarnos de seguir las normas del ajedrez y de que permite un análisis pausado de la partida.

Notas al pie de página:

1

Me está ayudando un amigo, pero no quiere que lo mencione, ni le atribuya méritos en público, así que no diré su nombre, apodo, ni dato alguno que pueda identificarlo... Pero no puedo dejar de reconocer que anda detrás de mí con el látigo: me corrige el código, lo simplifica, le encuentra errores, me echa la bronca por mi poca seriedad con los comentarios de los commits o mi falta de acierto a la hora de poner nombre a las funciones o escribir comentarios... en fin, mi Pepito Grillo personal sin el cual este proyecto llevaría haciendo aguas casi desde sus inicios.

2

Sí, ya sé que hay muchos, pero no todos hacen lo que queremos.

3

Notación de Forsyth-Edwars

4

Standard Algebraic Notation SAN, es la notación oficial de la FIDE para representar las partidas en campeonatos y también es la base de representación para los archivos PGN (Portable Game Notation).

5

El ajedrez 960 lo propuso Bobby Fisher. En esta modalidad se forma la primera línea de las figuras de manera aleatoria. https://es.wikipedia.org/wiki/Ajedrez_aleatorio_de_Fischer

Categoría: ajedrez nim

Comentarios

Debido a algunos ataques mailintencionados a través de la herramienta de comentarios, he decidido no proporcionar dicha opción en el Blog. Si alguien quiere comentar algo, me puede encontrar en esta cuenta de Mastodon, también en esta otra cuenta de Mastodon y en Diaspora con el nick de Notxor.

Si usas habitualmente XMPP (si no, te recomiendo que lo hagas), puedes encontrar también un pequeño grupo en el siguiente enlace: notxor-tiene-un-blog@salas.suchat.org

Disculpen las molestias.