featured image

Para uno de los capítulos de Programando Otra Historia estuve haciendo un desarrollo con Gambas3 y como hace tiempo que no programo en BASIC y quería hacerlo rápido dije: "Vamos a usar Copilot, ¿qué puede salir mal?"

El código está abierto, por si quieres presenciar el desastre: Puzzle8.

En principio instalé Debian 12 (Bookworm) como máquina virtual: git y gambas3. Todo en un entorno GNOME. Al principio Gambas no quería iniciarse y parece que faltaba especificar el uso de GTK3, puedes ver todo lo necesario para el proyecto en su README.md.

Una vez dibujado el primer formulario comencé la dinámica de forma inocente y simple con una pregunta:

estoy usando Gambas y quiero generar un tablero de puzzle 15 barajado, ¿cómo puedo hacer ese código?

La respuesta muy entusiasta, y propone el primer código:

¡Genial! Crear un tablero de puzzle 15 barajado en Gambas puede ser una actividad divertida e interesante. A continuación, te muestro un ejemplo de cómo puedes hacerlo en Gambas:

PUBLIC SUB Main()

  DIM board AS Integer[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
  DIM i AS Integer
  DIM temp AS Integer
  DIM index AS Integer
  
  ' Barajar el tablero usando el algoritmo de Fisher-Yates
  FOR i = 15 TO 1 STEP -1
    index = INT(Rnd(0, 1) * (i + 1))
    temp = board[i]
    board[i] = board[index]
    board[index] = temp
  NEXT
  
  ' Mostrar el tablero barajado
  FOR i = 0 TO 15
    PRINT board[i];
    IF ((i + 1) MOD 4) = 0 THEN PRINT
  NEXT

END

En principio, puedes ver que toma muchas asunciones:

  1. Crea un tablero dentro de un vector mono-dimensional.
  2. Utiliza un algoritmo de Fisher-Yates para barajar las piezas.
  3. Muestra el resultado por consola.

En principio, creé la interfaz con un conjunto de botones llamados Cell1, Cell2, Cell3, Cell4, ..., Cell15. Así que le indiqué su existencia para ver los cambios en la presentación:

  ' Posicionar los botones según el tablero barajado
  FOR i = 1 TO 15
    btn = Me["Cell" & i]
    x = (board[i - 1] MOD 4) * 50  ' Asumiendo que cada celda es de 50x50 píxeles
    y = (board[i - 1] \ 4) * 50
    btn.X = x
    btn.Y = y
  NEXT

Localiza cada pieza y la posiciona según su número y el contenido del tablero. Aquí se sucede un cambio sutil:

  1. Asume el tamaño de los botones 50x50 píxeles.
  2. Cambia el significado de board. El índice i es el número impreso en el botón y se emplea como posición para calcular x e y.

El primer caso podría haberse solucionado tomando btn.Width y btn.Height de una de las celdas. El hecho de agregar el número directamente sin siquiera sugerir una constante o variable queda mal.

El segundo es un fallo grave. El código correcto hubiese sido:

Dim num As Integer

For i = 1 To 15
  num = board[i - 1]
  If num > 0 Then
    btn = Me["Cell" & num]
    btn.X = ((i - 1) MOD 4) * btn.Width
    btn.Y = ((i - 1) \ 4) * btn.Height
  EndIf
Next

Porque se supone que el índice i recorre el tablero y por lo tanto el punto x e y parte del cálculo bidimensional de ese valor. Por otro lado, el contenido de board es el número que hay que localizar dado el nombre de la celda (CellN).

Esto no lo tuve en cuenta, como dije, quise acabar rápido y por tanto, es una deuda técnica el cambio de concepto de almacenar el tablero board a ahora tener el contenido de las cells.

Como me centré en la visualización gráfica y que no me estropeara mucho lo que se veía le di información sobre la vista más precisa:

la celda es de 56x48 y la primera celda comienza en (8, 16) teniendo una distancia de 8 entre cada botón

Dándome un código ajustado, pero como dije, ya cambiado el concepto de qué es lo que realmente contiene board:

  FOR i = 1 TO 15
    btn = Me["Cell" & i]
    x = 8 + (board[i - 1] MOD 4) * (56 + 8)  ' 56 es el ancho de la celda, 8 es la distancia entre celdas
    y = 16 + (board[i - 1] \ 4) * (48 + 8)  ' 48 es la altura de la celda, 8 es la distancia entre celdas
    btn.X = x
    btn.Y = y
  NEXT

Curiosamente, creé todos los botones bajo un mismo grupo y eso permitió nombrar el procedimiento a cargo de la gestión del evento de presionado como Cells_MouseDown, solo quedaba la función, siguiente entrada:

vale, he creado la sub Cells_MouseDown() que se ejecuta cada vez que una pieza es presionada, solo deben desplazarse las piezas que tengan el hueco cerca y hacia el hueco modificando su posición en el tablero

PRIVATE EMPTY_CELL AS Integer[] = [3, 3] ' Posición inicial del hueco (x, y)

PUBLIC SUB Cells_MouseDown()

  DIM btn AS Button
  DIM x AS Integer
  DIM y AS Integer
  DIM btnIndex AS Integer
  DIM cellIndex AS Integer

  ' Identificar el botón presionado
  btn = LAST.Control
  btnIndex = VAL(Right$(btn.Name, 2)) - 1
  
  ' Obtener la posición del botón presionado
  x = (btn.X - 8) / (56 + 8)
  y = (btn.Y - 16) / (48 + 8)
  
  ' Verificar si el hueco está cerca del botón presionado
  IF (ABS(EMPTY_CELL[0] - x) = 1 AND EMPTY_CELL[1] = y) OR _
     (ABS(EMPTY_CELL[1] - y) = 1 AND EMPTY_CELL[0] = x) THEN
       
    ' Mover el botón presionado al hueco
    btn.X = 8 + EMPTY_CELL[0] * (56 + 8)
    btn.Y = 16 + EMPTY_CELL[1] * (48 + 8)
    
    ' Actualizar la posición del hueco
    EMPTY_CELL[0] = x
    EMPTY_CELL[1] = y
    
    ' Actualizar el tablero
    cellIndex = EMPTY_CELL[1] * 4 + EMPTY_CELL[0]
    board[cellIndex] = btnIndex
  ENDIF

END

El código se ve bastante bien e incluso me gustó la parte de comprobar con valores absolutos si el movimiento es posible. No obstante, a los errores:

  1. LAST.Control no existe. En verdad es solo Last. Le pregunté sobre el error y no solo no supo de qué se trataba sino que dio otra solución creando un gestor de eventos que no es posible hacer en Gambas (al menos tal y como lo sugería), ¿alucinación o confusión con otro BASIC?
  2. Agrega un nuevo concepto EMPTY_CELL que en principio indica que será para puntos x e y, después veremos que no. Además, no aporta la parte del código donde debe inicializarse por primera vez.
  3. Por último, este código VAL(Right$(btn.Name, 2)). Si tomamos que las celdas de Cell1 a Cell9 solo tienen un número, obteniendo el texto desde la derecha con 2 posiciones obviamente dará l1, l2, ..., l9. Al intentar convertir con VAL(...) no nos origina un error pero retorna un valor nulo y por tanto falla el código.

Obviamente, no funcionó y tuve que hacer cambios para adecuar el código. Además, en este punto me di cuenta de que mejor que botones, se quedarían etiquetas de texto planas. Así que cambié, le hice saber y modificó parte del código para tomar el control.

Voy a saltarme la parte en la que le solicité un modal, porque aunque dio información correcta fue imprecisa y me dejó con muchas dudas que después no supo resolver.

El puzzle no tiene solución

Uno de los problemas de puzzle-8, puzzle-15 y toda esta gama de puzzles es que si utilizas un algoritmo aleatorio para ir introduciendo las piezas, esto es equivalente a extraer la piezas físicas del puzzle y volver a introducirlas desordenadas.

El problema es que estos puzzles no tienen soluciones a menos que partan de una solución o su composición cumpla con una ecuación concreta. A saber:

Si el lado (L) del puzzle, ya sea alto o ancho porque suelen ser cuadrados, es par o impar difiere la forma de comprobar la solución:

  1. Si L es impar (puzzle-8 que es un 9x9), entonces las inversiones de sus números deben ser pares. A continuación veremos qué son las inversiones.
  2. Si L es par, entonces depende de en qué fila esté el hueco (FH):
    1. Si la FH es impar entonces las inversiones de sus números deben ser impares.
    2. Si la FH es par entonces las inversiones de sus números deben ser pares.

¿Qué son las inversiones? Te preguntarás ahora, no te preocupes, yo también me lo pregunté. Cuando escribimos todos los números en orden en una sola línea (mono-dimensional) comenzamos a apuntar para el primero cuántos números le siguen que sean mayores.

La suma de todos estos números mayores son las inversiones.

Una vez tengamos las inversiones para todos los números, sumamos y debe salir un número par o impar.

Pidamos barajar de forma válida

cómo puedo hacer un código de barajado que sea seguro que generará un tablero resoluble sin que se extienda mucho el código?

Entonces, tenemos el código:

PUBLIC SUB Main()
  ' Inicializar el tablero resuelto
  board = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0]
  
  ' Realizar 100 movimientos aleatorios para barajar el tablero
  DO FOR i = 1 TO 100
    MakeRandomMove()
  LOOP

  ' Posicionar las etiquetas en el formulario según el tablero barajado
  PositionLabels()
END

En principio, el código no es válido porque los datos iniciales para el tablero no son válidos. Esto lo reescribí como:

  board = New Integer[Max_pieces + 1]
  For i = 0 To Max_pieces
    board[i] = i
  Next

Huelga decir que yo sí cree muchas variables al inicio del código par intentar usar cuantos menos números, mejor.

En el procedimiento PositionLabels() no hice caso, es buena idea crear funciones y procedimientos, sobre todo cuando reutilizamos o queremos dejar el código más comprensible pero en este caso no lo vi necesario.

El bucle tal y como lo presenta tampoco me pareció correcto y simplemente dejé:

For i = 1 To 100
    make_random_move
Next

Ahora, viene el plato fuerte. Esta función es un despropósito:

' Función para realizar un movimiento aleatorio
PRIVATE SUB MakeRandomMove()
  DIM moves AS Integer[] = [-1, 1, -4, 4] ' Movimientos posibles: izquierda, derecha, arriba, abajo
  DIM move AS Integer
  DIM newPos AS Integer
  
  DO
    move = moves[INT(Rnd(0, 1) * moves.Max)]
    newPos = (EMPTY_CELL[1] * 4 + EMPTY_CELL[0]) + move
  LOOP UNTIL newPos >= 0 AND newPos < 16 AND _
             ((move = -1 AND EMPTY_CELL[0] > 0) OR _
              (move = 1 AND EMPTY_CELL[0] < 3) OR _
              (move = -4 AND EMPTY_CELL[1] > 0) OR _
              (move = 4 AND EMPTY_CELL[1] < 3))
  
  ' Intercambiar la celda vacía con la nueva posición
  board[EMPTY_CELL[1] * 4 + EMPTY_CELL[0]] = board[newPos]
  board[newPos] = 0
  EMPTY_CELL[0] = newPos MOD 4
  EMPTY_CELL[1] = newPos \ 4
END

La idea de poner los posibles movimientos en un vector me gustó. No obstante y porque cambié de Puzzle-15 a Puzzle-8 preferí usar variables en lugar de esos -4, 4.

Después, volvemos al problema de que board no es el tablero per se y por tanto el intercambio y la búsqueda de posición no son correctas.

En este punto mezcla lo que contiene EMPTY_CELL que recordemos inicialmente proponía los puntos x e y pero en píxeles. También me di cuenta de un pequeño detalle. Como board no es el tablero sino que son las celdas numeradas, la celda con el número 15 es la correspondiente al hueco. Así que se lo hice saber:

he decidido almacenar en empty_cell los valores reales de X e Y porque en board[15] siempre se almacena el hueco

En principio lo ve perfecto, pero sigue usando el mismo código. Después de intentar aclararlo un par de veces le planté mi código y le pedí que implementase la función desde cero:

PRIVATE SUB make_random_move()
  DIM moves AS Integer[] = [-1, 1, -4, 4] ' Movimientos posibles: izquierda, derecha, arriba, abajo
  DIM move AS Integer
  DIM newPos AS Integer
  
  DO
    move = moves[INT(Rnd(0, 1) * moves.Max)]
    newPos = (empty_cell[1] * 4 + empty_cell[0]) + move
  LOOP UNTIL newPos >= 0 AND newPos < 16 AND _
             ((move = -1 AND empty_cell[0] > 0) OR _
              (move = 1 AND empty_cell[0] < 3) OR _
              (move = -4 AND empty_cell[1] > 0) OR _
              (move = 4 AND empty_cell[1] < 3))
  
  ' Intercambiar la celda vacía con la nueva posición
  board[(empty_cell[1] * 4 + empty_cell[0])] = board[newPos]
  board[newPos] = 0
  empty_cell[0] = newPos MOD 4
  empty_cell[1] = newPos \ 4
END

Sigue mal. En principio, como no usa LET y move es una palabra reservada hay conflicto. Después el cambio entre PascalCase, camelCase y snake_case me mareaba. Eso intenté homogeneizarlo en el código final.

Pero el código sigue mal. Le digo:

recuerda que empty_cell no contiene la posición de fila, columna, sino la posición real de X e Y

Ni caso. Intento otro enfoque:

dime una cosa, este código:

emptyIndex = (empty_cell[1] - 16) / (48 + 8) * 4 + (empty_cell[0] - 8) / (56 + 8)

¿no sería igual a este otro?

emptyIndex = board[15]

Me responde que no exactamente porque una calcula la posición de la celda vacía para obtener el índice del hueco y la otra es el índice del hueco :-|

Le vuelvo a decir:

creo que te estás equivocando porque emptyIndex es el índice dentro de board para obtener el hueco y eso se hace con board[15]

Y ahora ya sí, ve la luz: Tienes razón.

Pero el código sigue sin funcionar. Le comento:

creo que has liado lo que son los índices con lo que son las posiciones, en el tablero, los índices hacen mención al número que representan y por eso board[15] es siempre el hueco, pero board[newPos] no es válido porque los índices de board no son posiciones

Finalmente, me puse a pensar en qué había hecho mal Copilot y lo solucioné yo mismo.

Conclusiones

Esta conversación podría ser la que se obtiene con un programador junior, no digo que no, hay gente para todo y estoy seguro de que si te toca una persona arrogante que no quiere escuchar o que la lía, te pasará esto.

El problema con Copilot es que es un chat. No podemos hacer revisiones en partes del código específico e iterar sobre una base de código. El código es tratado y reescrito desde cero cada vez y eso es muy confuso y difícil de llevar. Sobre todo cuando la base de código crece.

Copilot es una gran herramienta y hace fácil algunas tareas, pero como se ve, depender en exceso de ella puede llevar a deuda técnica en un estadio muy temprano del desarrollo. Ya ni decir de situaciones donde haya mayor stress y presión por obtener algo para producción. Esto puede ser un desastre.

En mi opinión, toma estas herramientas con cautela, pero tómalas.