Búsqueda minimax

Ya sabemos generar los movimientos posibles para ambos bandos, tenemos unas estructuras de datos (tablas hash) donde guardar el mejor movimiento para cada tablero (siendo la secuencia de esos mejores movimientos la variante principal), sabemos desarrollar el árbol de juego (probar movimientos con “makeMove” y deshacerlos con “takeBack”) y ya, por último, somos capaces de evaluar las hojas del árbol con la función de evaluación. Por tanto, tenemos todas las piezas para montar la búsqueda, es decir, el proceso por el cual el C64 decidirá su siguiente jugada.

Hasta ahora, hemos dicho que ese proceso de búsqueda sería minimax, es decir, un proceso consistente en:

  • Desarrollar el árbol de juego hasta las hojas.
  • Aplicar la función de evaluación sobre las hojas.
  • Hacer subir la evaluación hacia la raíz, seleccionando el máximo o el mínimo en función del turno de juego (ratón = máximo, gatos = mínimo).

Esto se puede hacer, y funciona, pero tiene un problema: el factor de ramificación.

El factor de ramificación es el número de tableros hijo que tiene un tablero. De media, si mueve el ratón, el factor de ramificación será cuatro, porque hay cuatro movimientos posibles, y si mueven los gatos será ocho, porque hay dos movimientos posibles por cuatro gatos. Al final, si la profundidad de análisis es ocho, eso nos da:

4 x 8 x 4 x 8 x 4 x 8 x 4 x 8 ≈ 1 millón de tableros

Es decir, habría que generar y evaluar más de un millón de tableros. Y eso a profundidad máxima (ocho), sin tener en cuenta los tableros intermedios a profundidad siete, a profundidad seis, a profundidad cinco, etc.

Total, aunque estemos hablando de poco tiempo para generar y evaluar cada tablero, al multiplicar por un millón de tableros, nos vamos a bastantes minutos. Téngase en cuenta que el C64 es un ordenador de 1 MHz.

La conclusión es que no podemos generar y evaluar el árbol completo. No es viable. Hay que podarlo. Y aquí es donde entra la búsqueda alfa – beta que veremos en las entradas que siguen.

Función de evaluación

La función de evaluación es una rutina que recibe un tablero (tablero “board”) y devuelve un número que nos indica cómo de bueno o malo es ese tablero para ratón y gatos. Se aplica sobre las hojas del árbol de juego, y luego esas evaluaciones se hacen subir por el árbol aplicando minimax, es decir, calculando el máximo o el mínimo en función del bando que mueve:

Las funciones de evaluación pueden llegar a ser muy sofisticadas en juegos complejos como el ajedrez. Suelen tener en cuenta muchos criterios, que se suelen clasificar en:

  • Posicionales: Los que tienen que ver con la posición de las piezas.
  • Materiales: Los que tienen que ver con el número y valor de las piezas.

En el caso del ratón y el gato, como no hay capturas, las piezas son siempre las mismas y, por tanto, no hay criterios materiales. Sólo hay criterios posicionales.

Tablas de evaluación:

La función de evaluación se apoya en unas tablas llamadas “tablas de evaluación”. Estas tablas están definidas en el fichero “Init.asm”, aunque igualmente se podrían haber definido en el nuevo fichero “Eval.asm”, que tiene que ver con la evaluación.

Hay una tabla por cada tipo de pieza y, puesto que en su momento decidimos que los gatos eran cuatro piezas distintas (“PIECE_CAT1”, …, “PIECE_CAT4”), con el objeto de poder distinguirlos, eso da lugar a cinco tablas:

La tabla de evaluación “mouse_scores” nos dice cuánto vale el ratón en función de su posición (criterio posicional). Si el ratón está en la fila cero vale cero, si está en la fila uno vale uno, y así sucesivamente. Es decir, vale tanto más cuanto más cerca esté de la última fila, porque el objetivo del ratón es llegar a la última fila.

En el caso de un gato, la tabla de evaluación “cat*_scores” nos dice que vale 28 cuando está en la fila siete (fila inicial de los gatos), 27 cuando está en la fila seis, 25 cuando está en la fila cinco, y así sucesivamente. Aquí hay que observar dos cuestiones importantes:

  • De la fila siete a la seis el gato pierde un punto (28-27=1), pero de la seis a la cinco pierde dos (27-25=2), de la cinco a la cuatro pierde tres (25-22=3), y así sucesivamente. Cuanto más avanza más valor pierde.
  • Aunque el gato 1, por ejemplo, puede moverse en teoría por todo el tablero (respetando sus reglas de movimiento, claro), en la práctica, por cómo hemos diseñado su tabla de evaluación, el gato 1 va a intentar no salirse de las columnas 1 y 0. Si se saliera de ahí, lo cual está permitido por las reglas de movimiento, perdería todo su valor.

Estos dos “trucos” tienen como efecto combinado que, antes que mover un gato varias veces seguidas y romper la formación de gatos, el C64 preferirá mover los gatos en formación, es decir manteniendo la fila, y de este modo intentará impedir que el ratón los rebase, lo cual le daría la victoria.

En definitiva, las tablas de evaluación están diseñadas para que el ratón y los gatos busquen sus respectivos objetivos en el juego, que son llegar a la última fila en el caso del ratón, y acorralar al ratón en el caso de los gatos.

Rutina “eval”:

Una vez vistas las tablas de evaluación, lo que hace la función de evaluación, que es la rutina “eval” del nuevo fichero “Eval.asm”, es esto:

Es decir:

  • Define tres valores, “evMouse” con la valoración del tablero para el ratón, “evCats” con la valoración para los gatos, y “ev” que es la valoración global.
  • Inicializa los tres valores a cero.
  • Recorre el tablero “board” con el índice X.
  • Si una posición X contiene el ratón (“#PIECE_MOUSE”), usa la tabla de evaluación del ratón (“mouse_scores”) para determinar el valor de “evMouse”.
  • Si una posición X contiene un gato (“#PIECE_CAT*”), usa la tabla de evaluación de ese gato (“cat*_scores”) para determinar el valor de “evCats”. Se va sumando al valor anterior.
  • Si la posición está vacía, lógicamente se pasa a la siguiente.

Por último, queda hacer algunos cálculos:

Como se puede ver, en la parte final de “eval”, a partir de la etiqueta “evEnd”, se aplica la siguiente fórmula:

ev = $80 + evMouse – evCats

Es decir, la evaluación global “ev” es igual a $80 = 128, más la evaluación del ratón, menos la evaluación de los gatos.

Y como la evaluación máxima de los gatos es 28 x 4 = 112, eso da lugar a una evaluación mínima de 128 + 0 – 112 = 16 = $10. Esta evaluación es la que corresponde al tablero inicial. A partir de ahí, los gatos irán bajando y el ratón, en general, irá subiendo, lo que en teoría podría dar lugar a una evaluación máxima de 128 + 7 – 0 = 135 = $87.

Es decir, la evaluación global es un número que oscila entre $10 = 16 y $87 = 135. De hecho, el objetivo de sumar $80 = 128 es manejar y comparar siempre números positivos, lo cual es más fácil que manejar números positivos y negativos.

Nuevo programa principal:

Ya sólo nos queda probar todo esto para lo que, como siempre, hacemos un nuevo programa principal:

Este nuevo programa principal:

  • Inicializa el tablero y la partida con “initBoard”.
  • Inicializa las tablas aleatorias para el hash y el lock con “randomize”.
  • Inicializa el proceso de búsqueda con “newPosition”, aunque de momento todavía no vamos a buscar la mejor jugada, aunque nos vamos acercando.
  • Muestra el tablero con “displayBoard”, que ha sido mejorada para mostrar la evaluación asociada a un tablero.
  • Con “makeMove”, realiza el movimiento 4 => 13, es decir, el movimiento E1 => F2, y vuelve a mostrar el tablero.
  • Con “makeMove”, realiza el movimiento 57 => 48, es decir, el movimiento B8 => A7, y vuelve a mostrar el tablero.

Si lo ejecutamos observamos estos tres tableros:

Es decir, la evaluación empieza en $10, con el movimiento del ratón pasa a $11, y con el movimiento del gato pasa a $12.

Como podemos ver, la evaluación del tablero depende de las posiciones de las piezas. Y como el C64 elegirá como óptimos los movimientos que maximizan o minimizan la evaluación, en función del turno, en el fondo lo que tenemos es que las tablas de evaluación (“*_scores”) influyen en los movimientos que se eligen y realizan.


Código del proyecto: RYG3-06

Árbol de juego / actualización del tablero III

Ya sabemos aplicar un movimiento con “makeMove” y deshacerlo con “takeBack”. Ahora vamos a probar estas rutinas, pero antes una rutina preparatoria que es necesaria.

Rutina “newPosition”:

La rutina “newPosition” es una nueva rutina del fichero “Init.asm”:

Esta rutina es parecida en su función a “InitBoard” pero, en vez de inicializar el tablero y la partida, inicializa el siguiente movimiento. Es decir, prepara todo para que se pueda decidir el siguiente movimiento:

  • Mete cero en la puntuación del ratón (“mouse_score”).
  • Mete cero en la puntuación de los gatos (“cats_score”).
  • Obtiene el hash del tablero actual con “getHash” y lo guarda en “current_hash”.
  • Obtiene el lock del tablero actual con “getLock” y lo guarda en “current_lock”.

Todavía no hemos llegado a la fase de elegir la mejor jugada. Para esto habrá que evaluar las hojas y aplicar minimax.

Todavía estamos en la fase previa de desarrollar el árbol de juego con “makeMove” y “takeBack”. Pero cuando llegue el momento de decidir la siguiente jugada, antes de hacerlo, habrá que llamar a “newPosition” para preparar el proceso de decisión, metiendo cero en las puntuaciones y actualizando hash y lock.

Nuevo programa principal:

Con el objeto de probar “makeMove” y “takeBack” hacemos un nuevo programa principal sencillo:

Este nuevo programa principal:

  • Inicializa el tablero llamando a “initBoard”.
  • Llamando a “randomize”, asigna valores aleatorios a las tablas que se utilizan para generar el hash y el lock. Nota: si en vez de llamar a “randomize” se llama a “fixedHash” se usan unos valores fijos para las tablas aleatorias, lo cual facilita la depuración por usar siempre los mismos valores, y así facilitar la repetición y la comparación.
  • Inicializa el proceso de decisión de la siguiente jugada llamando a “newPosition”. De momento, no vamos hacer un proceso de decisión completo, sólo vamos a probar “makeMove” y “takeBack”, pero la llamada a “newPosition” sirve para calcular el hash y el lock del tablero actual (que es el tablero inicial).
  • Imprime el tablero llamando a “displayBoard”.
  • Hace un movimiento desde la casilla 4 hasta la casilla 13, es decir, mueve el ratón desde la casilla E1 = 4 hasta la casilla F2 = 13. Esto lo hace con “makeMove”.
  • Vuelve a imprimir el tablero con “displayBoard”.
  • Deshace el último movimiento, es decir, devuelve el ratón desde la casilla F2 hasta la casilla E1.
  • Vuelve a imprimir el tablero con “displayBoard”. Obsérvese que, además del ratón, el hash y el lock, el bando, y el número de jugadas también vuelven a sus valores originales.

De este modo, el resultado es así:

Tras aplicar el movimiento E1F2, obsérvese que, además de cambiar la posición del ratón a F2, también cambian el bando que mueve (S: C), el número de jugadas (P:01 y H:01), el hash (#:42) y el lock (L:BE).

Contrariamente, tras deshacer el movimiento, obsérvese que, además de volver el ratón a la posición E1, también vuelven a sus valores originales el bando que mueve (S: M), el número de jugadas (P:00 y H:00), el hash (#:F3) y el lock (L:63).

Por tanto, parece que “makeMove” y “takeBack” funcionan bien. Así que el siguiente paso ya será evaluar los tableros.


Código del proyecto: RYG3-05

Árbol de juego / actualización del tablero II

Nos acercamos ya a dos de las rutinas principales del juego, que son las rutinas:

  • Rutina “makeMove”: Esta rutina sirve para ejecutar un movimiento, ya sea con carácter definitivo (porque se ha seleccionado como el mejor) o con carácter provisional (porque se va a analizar en el proceso de desarrollo del árbol de juego o búsqueda).
  • Rutina “takeBack”: Esta rutina sirve para deshacer el último movimiento. Esto sólo tiene sentido durante el proceso de búsqueda, o si se ofrece una funcionalidad de rectificar el último movimiento.

Estas rutinas están al mismo nivel de importancia que otras ya vistas, por ejemplo, “gen” para generar las jugadas posibles, o “think” y “search” que veremos más adelante, y que sirven para desarrollar el árbol de juego hasta una profundidad N.

Rutina “makeMove”:

Como ya se ha adelantado, esta rutina sirve para ejecutar un movimiento. Está en el fichero “Update.asm”:

Esta rutina recibe un movimiento, es decir, una casilla de origen y una casilla de destino, y hace lo siguiente:

  • Guarda el movimiento (origen, destino) en la lista “game_list”. Esto es lo que va a permitir deshacer el movimiento más adelante, si fuera necesario.
  • Incrementa el número de jugadas históricamente jugadas desde el comienzo de la partida (“hply”) y el número de jugadas de la variante actual (“ply”).
  • Busca la pieza que está en la posición de origen, y la mueve a la posición de destino con “updatePiece”. Recordemos que “updatePiece” actualiza el tablero “board” y el hash / lock con “updateHash”.
  • Cambia el bando al que le toca mover (“side”).

Rutina “takeBack”:

Por su parte, la rutina “takeBack”, que también es del fichero “Update.asm”, es así:

Viene a hacer lo contrario que la rutina “makeMove”:

  • Vuelve a cambiar el bando al que le toca mover (“side”).
  • Decrementa el número de jugadas de la variante actual (“ply”) y las históricamente jugadas (“hply”) desde el inicio de la partida.
  • Busca en “game_list” el origen y el destino del último movimiento.
  • Usando “updatePiece”, mueve la pieza que hay en el destino desde el destino hasta el origen. Es decir, deshace el último movimiento.

Con estas dos rutinas, “makeMove” y “takeBack”, ya somos capaces de ejecutar y deshacer movimientos. Y es importante tener claro que hay dos situaciones en las que se ejecutan movimientos:

  • Durante el proceso de búsqueda o desarrollo del árbol de juego. En esta situación los movimientos se ejecutan para probar cómo de buenos o malos son. Estos movimientos hay que deshacerlos.
  • A partir del proceso de búsqueda anterior, y una vez que el C64 ha decidido cuál es la jugada que más le interesa, para ejecutar esa jugada con carácter definitivo.

Todo esto lo probaremos ya en la siguiente entrada.


Código del proyecto: RYG3-05

Árbol de juego / actualización del tablero I

Recapitulando, ya sabemos:

  • Cómo representar el tablero en memoria del C64.
  • Como inicializar e imprimir el tablero.
  • Cómo generar jugadas para ambos bandos.
  • Cómo calcular el hash de un tablero.
  • Cómo guardar el mejor movimiento asociado a un tablero en una tabla hash, reduciendo el impacto de las colisiones mediante un lock.

Por tanto, parece lógico que los siguientes pasos deberían ser:

  • Desarrollar el árbol de juego hasta una profundidad N.
  • Evaluar las hojas del árbol.
  • Llevar esa evaluación hacia la raíz aplicando minimax.
  • En cada tablero del árbol, seleccionar la mejor jugada del bando que mueva y almacenarlo en la tabla hash.

De este modo, al terminar el proceso, en la tabla hash (en realidad, en las tablas hash, puesto que hay una por bando) quedará almacenada la variante principal. Es decir, quedará almacenada la línea de juego que, según el C64, es la mejor secuencia de jugadas para ambos bandos con la información conocida.

Desarrollar el árbol de juego:

Para “desarrollar el árbol de juego” vamos a generar y aplicar movimientos, pero no vamos a almacenar en memoria el árbol de jugadas completo. Este enfoque consumiría demasiada memoria. Lo que vamos a hacer, en cambio, es ir aplicando movimientos al tablero “board” y luego ir deshaciéndolos.

Es decir, desde un punto visual es como si fuéramos moviendo el tablero o la variante actual por el árbol de jugadas (1º, 2º, 3º, 4º, …):

Cuando la variante actual esté en las hojas del árbol invocaremos la función de evaluación (a detallar más adelante) para evaluar los tableros, y cuando esté en un nodo intermedio calcularemos el máximo o el mínimo de las evaluaciones de los tableros hijo, según el bando al que le toque mover (minimax).

Al final lo que hacemos es recorrer el árbol de juego “en profundidad”: primero la primera rama hasta su máxima profundidad, luego la segunda rama hasta su máxima profundidad, y así sucesivamente hasta la última rama. Y lo mejor de todo es que no necesitamos almacenar en memoria todos los tableros del árbol a la vez. Llega con un único tablero (“board”) sobre el que vamos aplicando y deshaciendo movimientos.

Deshacer movimientos:

Lógicamente, para poder deshacer un movimiento tenemos que saber cuál fue el último movimiento que se aplicó. Sólo el último movimiento se puede deshacer. Para esto se utiliza la lista “game_list” del fichero “Init.asm”.

“game_list” es similar a “move_list”, pero en vez de almacenar los movimientos posibles a partir de un tablero, almacena los movimientos jugados en el pasado, de modo que el último movimiento de “game_list” es el único que se puede deshacer. “game_list”, igual que “move_list”, es doble en realidad, ya que los movimientos tienen un origen y un destino. Por tanto, tenemos “game_list_start” y “game_list_dest”.

Veamos cómo actualizar el tablero para ir recorriendo el árbol de juego.

Rutina “updatePiece”:

La rutina “updatePiece” forma parte de un nuevo fichero “Update.asm”. En este fichero van a estar todas las rutinas relativas a la actualización del tablero, es decir, relativas a aplicar y deshacer movimientos.

Es decir, esta rutina básicamente:

  • Añade una pieza (“upPiece”) a la casilla de destino (“upDest”).
  • Y mete vacío (“#PIECE_EMPTY”) en la casilla de origen (“upStart”).

Pero además de eso, llama a la rutina “updateHash” para actualizar el hash y el lock del tablero como consecuencia de la pieza movida (recordemos que el hash y el lock se derivan de las piezas y sus posiciones).

La rutina “updateHash”, que es del fichero “Hash.asm”, es muy similar a la ya vista “getHash”. “getHash” calcula el hash del tablero teniendo en cuenta todas sus piezas; “updateHash” actualiza el hash y el lock del tablero teniendo en cuenta la pieza de una posición concreta.

De hecho, “updatePiece” llama dos veces a la rutina “updateHash”:

  • La primera vez con la pieza que se mueve (“upPiece”) y la casilla de origen (“upStart”). Esto es para reflejar en el hash y en el lock que la pieza ya no está en la casilla de origen.
  • Y la segunda vez con la pieza que se mueve (“upPiece”) y la casilla de destino (“upDest”). Esto es para reflejar en el hash y en el lock que la pieza ahora está en la casilla de destino.

Con todos estos mimbres (lista de jugadas “game_list” y rutina “updatePiece”) ya es posible abordar las rutinas para hacer y deshacer movimientos. Pero esto ya lo haremos en la siguiente entrada.


Código del proyecto: RYG3-05