juegos

Juegos de terminal de Pancho: Catan (TUI, GUI, web, servidor, PicoCalc), ajedrez, calculadora y minijuegos
git clone https://git.lu3dhn.xyz/juegos.git
Log | Files | Refs

ches.sh (30311B)


      1 #!/usr/bin/env bash
      2 # ============================================================================
      3 #  ches.sh  --  El PUNTO DE ENTRADA del juego de ajedrez en bash.
      4 # ============================================================================
      5 #
      6 # Este es el archivo que se ejecuta para jugar (./ches.sh). Es "delgado": casi no
      7 # tiene logica propia. Lo que hace es: cargar las librerias de lib/, preparar el
      8 # modo de juego elegido, y correr el bucle principal de la partida (la funcion
      9 # play()). Toda la inteligencia esta repartida en lib/.
     10 #
     11 # El motor de reglas no sabe de donde viene cada jugada: puede venir del selector
     12 # visual (fzf), de otra persona por red, o de la computadora (Stockfish). Eso es
     13 # lo que mantiene la red y la IA separadas de las reglas. Ver lib/ para el detalle.
     14 #
     15 # Formas de usarlo:
     16 #   ./ches.sh                        abre el menu interactivo
     17 #   ./ches.sh hotseat                dos personas en esta misma compu
     18 #   ./ches.sh ai                     contra la computadora (necesita Stockfish)
     19 #   ./ches.sh host <port>            hacer de servidor (jugas blancas)
     20 #   ./ches.sh join <host> <port>     unirse a una partida (jugas negras)
     21 #   ./ches.sh server <host> <port>   jugar via un relay (ver bin/chess-server.sh)
     22 #   ./ches.sh resume <file>          retomar una partida guardada (ver saves/)
     23 #   ./ches.sh fen "<FEN>"            empezar desde un FEN pegado
     24 #   ./ches.sh pgn <file>             reproducir/seguir una partida desde un PGN
     25 #   ./ches.sh rejoin <host> <port>   reconectarse a una partida en curso
     26 #   ./ches.sh --selftest             correr las pruebas automaticas
     27 #   ./ches.sh --help                 mostrar esta ayuda
     28 
     29 # Averiguamos la carpeta donde vive este archivo (ver explicacion en
     30 # bin/get-stockfish.sh). La usamos para cargar las librerias con rutas absolutas.
     31 SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
     32 # "source" carga otro archivo de bash dentro de este, como si lo pegaramos aca.
     33 # El ORDEN importa: cada uno usa cosas de los anteriores.
     34 source "$SCRIPT_DIR/lib/board.sh"      # el tablero y las coordenadas
     35 source "$SCRIPT_DIR/lib/movegen.sh"    # jugadas posibles de cada pieza
     36 source "$SCRIPT_DIR/lib/rules.sh"      # reglas, jaques, fin de partida
     37 source "$SCRIPT_DIR/lib/notation.sh"   # traduccion a notacion SAN (y PGN)
     38 source "$SCRIPT_DIR/lib/ai.sh"         # la computadora (Stockfish)
     39 source "$SCRIPT_DIR/lib/ui.sh"         # dibujar el tablero y los menus
     40 source "$SCRIPT_DIR/lib/protocol.sh"   # mensajes y conexion de red (bajo nivel)
     41 source "$SCRIPT_DIR/lib/transport.sh"  # de donde viene cada jugada + la red
     42 source "$SCRIPT_DIR/lib/saves.sh"      # guardar/cargar/importar/exportar
     43 
     44 # Ruta absoluta a este script. La vista previa del selector (en ui.sh) la necesita
     45 # para volver a llamarse a si misma. "export" hace que la vean los subprocesos.
     46 export CHESS_BIN="$(readlink -f "$0")"
     47 
     48 # Carpeta donde se guardan y se leen las partidas pausadas.
     49 SAVE_DIR="$SCRIPT_DIR/saves"
     50 
     51 # Cargamos la configuracion de ayudas visuales (SHOW_EVAL_BAR, SHOW_OPENING).
     52 # Tiene que ir DESPUES de fijar SAVE_DIR porque _SETTINGS_FILE depende de el.
     53 settings_load
     54 
     55 # usage: muestra la ayuda. Truco: en vez de repetir el texto, lo SACA del propio
     56 # archivo. "sed -n '4,20p'" imprime las lineas 4 a 20 (el bloque de comentarios de
     57 # arriba), y el segundo "sed" les saca el "# " del principio.
     58 usage() { sed -n '15,27p' "$CHESS_BIN" | sed 's/^# \{0,1\}//'; }
     59 
     60 # --- Historial de jugadas (lo que se muestra al costado del tablero) --------
     61 HIST_FULLMOVE=0   # numero de jugada completa actual
     62 HIST_LINE=""      # jugada de las blancas pendiente (cuando todavia no movieron las negras)
     63 # record_history: anota una jugada en el historial. Las blancas dejan su jugada
     64 # "pendiente" (con "..." donde iran las negras); cuando mueven las negras se
     65 # completa la linea y se pasa al siguiente numero.
     66 record_history() {
     67     local mover=$1 mv=$2
     68     if [[ "$mover" == white ]]; then
     69         HIST_LINE="$mv"
     70         HISTORY[$HIST_FULLMOVE]="$HIST_LINE  ..."
     71     else
     72         HISTORY[$HIST_FULLMOVE]="${HIST_LINE:-...}  $mv"
     73         ((HIST_FULLMOVE++))
     74         HIST_LINE=""
     75     fi
     76 }
     77 
     78 # --- Retroceso (takeback): "fotos" del estado, una por jugada ---------------
     79 # UNDO_STACK es una PILA: una lista donde apilamos una "foto" del estado ANTES de
     80 # cada jugada. Para deshacer, sacamos la ultima foto y volvemos a ella. Una foto
     81 # guarda: la posicion, los dos relojes, y las variables del historial. (La cuenta
     82 # de repeticiones POSCOUNT se maneja aparte, en do_takeback.) Solo se usa si los
     83 # retrocesos estan permitidos.
     84 declare -a UNDO_STACK=()
     85 
     86 # Registro PGN en vivo (la lista de jugadas en SAN y desde que posicion se
     87 # numeran). Se mantiene al dia y se guarda/serializa via saves.sh.
     88 declare -a PGN_SAN=()
     89 declare -a PGN_UCI=()   # las mismas jugadas en notacion UCI (para detectar aperturas)
     90 PGN_STARTFEN="$START_FEN"
     91 
     92 # _pgn_result: traduce el resultado interno de la partida al simbolo de resultado
     93 # del PGN (desde el punto de vista de las blancas): "1-0" gana blancas, "0-1" gana
     94 # negras, "1/2-1/2" tablas, "*" sin terminar.
     95 _pgn_result() {
     96     case "$1" in
     97         checkmate)                   [[ "$TURN" == white ]] && echo "0-1" || echo "1-0" ;;
     98         resign-white|timeout-white)  echo "0-1" ;;
     99         resign-black|timeout-black)  echo "1-0" ;;
    100         draw-*|stalemate)            echo "1/2-1/2" ;;
    101         *)                           echo "*" ;;
    102     esac
    103 }
    104 
    105 # _pgn_autosave: va guardando la partida en saves/.current.pgn a medida que se
    106 # juega (asi queda en disco como PGN, y sirve de respaldo ante un corte). Por
    107 # defecto el resultado es "*" (en curso).
    108 _pgn_autosave() {
    109     export_pgn "${SAVE_DIR:-saves}/.current.pgn" "${1:-*}" 2>/dev/null || true
    110 }
    111 
    112 # _snapshot_push: apila una "foto" del estado actual (antes de mover). Junta todo
    113 # en un texto separado por ";", con las listas pegadas por "|".
    114 _snapshot_push() {
    115     local h p; local IFS='|'; h="${HISTORY[*]}"; p="${PGN_SAN[*]}"
    116     UNDO_STACK+=( "$(get_fen);$CLOCK_white;$CLOCK_black;$HIST_FULLMOVE;$HIST_LINE;$h;$p" )
    117 }
    118 
    119 # do_takeback: deshace la ultima jugada. Saca la ultima foto de la pila y restaura
    120 # el estado, dejando el turno de quien acababa de mover. Le resta 1 a la cuenta de
    121 # repeticiones de la posicion que abandonamos. Devuelve 1 (no hace nada) si la
    122 # pila esta vacia.
    123 do_takeback() {
    124     (( ${#UNDO_STACK[@]} == 0 )) && return 1
    125     local curkey; curkey=$(position_key)        # la posicion que estamos dejando
    126     if [[ -n "${POSCOUNT[$curkey]:-}" ]]; then
    127         POSCOUNT[$curkey]=$(( POSCOUNT[$curkey] - 1 ))
    128         (( POSCOUNT[$curkey] <= 0 )) && unset 'POSCOUNT[$curkey]'
    129     fi
    130     # "${UNDO_STACK[-1]}" es el ultimo elemento; "unset ... [-1]" lo saca de la pila.
    131     local snap="${UNDO_STACK[-1]}"; unset 'UNDO_STACK[-1]'
    132     local fen cw cb hfm hline h p
    133     IFS=';' read -r fen cw cb hfm hline h p <<< "$snap"   # partimos la foto por ";"
    134     setup_fen "$fen"
    135     CLOCK_white=$cw; CLOCK_black=$cb
    136     HIST_FULLMOVE=$hfm; HIST_LINE="$hline"
    137     HISTORY=(); [[ -n "$h" ]] && IFS='|' read -r -a HISTORY <<< "$h"
    138     PGN_SAN=(); [[ -n "$p" ]] && IFS='|' read -r -a PGN_SAN <<< "$p"
    139     PGN_UCI=("${PGN_UCI[@]:0:${#PGN_SAN[@]}}")   # recortamos UCI para que coincida con SAN
    140     return 0
    141 }
    142 
    143 # --- play(): EL BUCLE PRINCIPAL de la partida -------------------------------
    144 # Aca pasa toda la partida: mientras no termine, pide la jugada a quien
    145 # corresponda, la aplica, y repite. Es el corazon que ata todo lo demas.
    146 play() {
    147     # Arrancamos (o retomamos) desde el estado que dejo listo el transporte: la
    148     # posicion inicial para una partida nueva, o una guardada/recibida si se retoma.
    149     adopt_game_state
    150 
    151     local result=""
    152     while true; do
    153         # Primero: la partida ya termino? (mate, ahogado, tablas automaticas...)
    154         local st; st=$(game_status)
    155         if [[ "$st" != ongoing ]]; then result="$st"; break; fi
    156 
    157         # Medimos el tiempo del que mueve DESDE ACA (el proceso principal): la
    158         # fuente de jugadas corre dentro de $( ) y un subproceso no puede cambiar el
    159         # reloj del padre. Por eso usamos "SECONDS" (un contador de segundos de
    160         # bash) alrededor de la llamada y actualizamos CLOCK_* aca.
    161         local mover=$TURN mv t0=$SECONDS
    162         if transport_is_local_turn; then
    163             # La vista previa del selector ES el tablero, asi que no dibujamos uno
    164             # fijo. Limpiamos la pantalla por las dudas: si fzf no puede usar la
    165             # "pantalla alternativa", dibuja en linea y quedaria el tablero de
    166             # "Waiting..." arriba. Limpiando, garantizamos uno solo.
    167             clear
    168             mv=$(transport_local_move)
    169         elif [[ "$TRANSPORT_MODE" == aivai ]]; then
    170             # Modo espectador: Stockfish mueve por los dos bandos. Mostramos el tablero
    171             # y esperamos el tiempo elegido (o una tecla en modo "step") antes de pedir
    172             # la siguiente jugada. La tecla 'q' termina la partida en cualquier momento.
    173             render_board
    174             echo "  Stockfish (${TURN^^}) thinking..."
    175             if [[ "$AI_SPECTATE_DELAY" == step ]]; then
    176                 printf '  Press any key for next move, q to quit...' >/dev/tty
    177                 local _sk; read -r -N1 _sk </dev/tty 2>/dev/null
    178                 echo
    179                 [[ "$_sk" == q || "$_sk" == Q ]] && { result="quit"; break; }
    180             elif (( AI_SPECTATE_DELAY > 0 )); then
    181                 local _sk
    182                 if read -r -t "$AI_SPECTATE_DELAY" -N1 _sk </dev/tty 2>/dev/null; then
    183                     [[ "$_sk" == q || "$_sk" == Q ]] && { result="quit"; break; }
    184                 fi
    185             fi
    186             mv=$(ai_move)
    187         elif [[ "$TRANSPORT_MODE" == ai ]]; then
    188             # La computadora es la fuente de jugadas. Su tiempo de pensar se le
    189             # cobra a su reloj con la medicion de SECONDS de mas abajo, igual que a
    190             # una persona.
    191             render_board
    192             echo "  Computer is thinking..."
    193             mv=$(ai_move)
    194         else
    195             # La pantalla de espera es interactiva: transport_recv_move hace andar
    196             # el reloj y mira el teclado (asi una partida sin tiempo igual puede
    197             # gatillar una reconexion manual: una caida dura no produce EOF).
    198             render_board
    199             local rt=""
    200             if (( TC_ENABLED )); then
    201                 [[ "$mover" == white ]] && rt=$(( CLOCK_white + 2 )) || rt=$(( CLOCK_black + 2 ))
    202             fi
    203             mv=$(transport_recv_move "$rt")
    204             echo
    205         fi
    206 
    207         # "mv" trae la jugada, o una palabra de control. Decidimos que hacer:
    208         case "$mv" in
    209             ""|QUIT)          result="quit";           break ;;   # salir
    210             RESIGN)           result="resign-$mover";  break ;;   # rendirse
    211             CLAIM50)          if can_claim_threefold; then result="draw-threefold"
    212                               else result="draw-50"; fi; break ;; # reclamar tablas
    213             TIMEOUT)          result="timeout-$mover"; break ;;   # se acabo el tiempo
    214             DRAWAGREE)        result="draw-agreement"; break ;;   # tablas acordadas
    215             TAKEBACK)         do_takeback                 # deshacer la ultima jugada
    216                               # contra la maquina, deshacemos tambien SU respuesta,
    217                               # asi te toca mover de nuevo a vos.
    218                               [[ "$TRANSPORT_MODE" == ai ]] && do_takeback
    219                               continue ;;
    220             # Reconexion: la maneja el proceso principal (no un subproceso) porque
    221             # es el duenio de la pantalla. "continue" vuelve a empezar el bucle.
    222             RECONNECT)        transport_reconnect auto   || { result="quit"; break; }; continue ;;
    223             RECONNECT-MANUAL) transport_reconnect manual || { result="quit"; break; }; continue ;;
    224         esac
    225 
    226         # Sacamos la "foto" para poder deshacer esta jugada. La tomamos ANTES de
    227         # cobrar el tiempo, asi al deshacer tambien se devuelve el tiempo gastado.
    228         _snapshot_push
    229 
    230         # Cobramos el tiempo usado y sumamos el incremento; si se acaba aca, fin.
    231         if (( TC_ENABLED )); then
    232             local elapsed=$(( SECONDS - t0 ))
    233             # "local -n clk=..." hace que "clk" sea un APODO (referencia) de la
    234             # variable CLOCK_white o CLOCK_black, segun quien mueva. Asi modificamos
    235             # el reloj correcto sin repetir codigo.
    236             local -n clk="CLOCK_$mover"
    237             clk=$(( clk - elapsed ))
    238             if (( clk <= 0 )); then result="timeout-$mover"; break; fi
    239             clk=$(( clk + TC_INC ))
    240         fi
    241 
    242         PGN_SAN+=( "$(move_to_san "$mv")" )   # anotamos la jugada en SAN (antes de aplicarla)
    243         PGN_UCI+=( "$mv" )                     # idem en UCI (para la deteccion de apertura)
    244         apply_move "$mv"
    245         record_history "$mover" "$mv"
    246         _pgn_autosave                          # guardamos el PGN en curso
    247     done
    248 
    249     _pgn_autosave "$(_pgn_result "$result")"   # PGN final, ya con el resultado real
    250 
    251     # Mostramos el tablero final y el mensaje de como termino la partida.
    252     render_board
    253     case "$result" in
    254         checkmate)        echo "  Game over: checkmate.";;
    255         draw-50)          echo "  Game over: draw claimed (50-move rule).";;
    256         draw-threefold)   echo "  Game over: draw claimed (threefold repetition).";;
    257         draw-fivefold)    echo "  Game over: draw — fivefold repetition.";;
    258         draw-75move)      echo "  Game over: draw — 75-move rule.";;
    259         draw-agreement)   echo "  Game over: draw agreed.";;
    260         stalemate|draw-*) echo "  Game over: draw.";;
    261         resign-*)         local who=${result#resign-} winner
    262                           winner=$(other_color "$who")
    263                           echo "  ${who^^} resigned. ${winner^^} wins.";;
    264         timeout-*)        local who=${result#timeout-} winner
    265                           winner=$(other_color "$who")
    266                           echo "  ${who^^} lost on time. ${winner^^} wins.";;
    267         quit)             echo "  Game ended.";;
    268     esac
    269     echo "  Game recorded to ${SAVE_DIR}/.current.pgn"
    270     transport_close   # cerramos red / motor si los habia
    271 
    272     # Solo ofrecemos analisis si termino una partida real (no una salida abrupta).
    273     [[ "$result" == quit ]] || ai_offer_analysis
    274 }
    275 
    276 # clock_ticker: actualizador del reloj en vivo del selector. Lo lanza fzf en
    277 # segundo plano al abrirse (por eso hereda $FZF_PORT, el puerto de control de fzf).
    278 # Cada segundo le manda a fzf un nuevo encabezado con el tiempo. Cuando se acaba,
    279 # deja la senial (sentinel) y le dice a fzf que se cierre. Termina cuando fzf ya no
    280 # esta (el envio con curl falla).
    281 clock_ticker() {
    282     local port=$1
    283     local deadline=${CHESS_DEADLINE:-0} active=${CHESS_ACTIVE:-white}
    284     local sw=${CHESS_CLK_W:-0} sb=${CHESS_CLK_B:-0} sent=${CHESS_SENTINEL:-}
    285     local now rem w b hdr
    286     while :; do
    287         now=$(date +%s); rem=$(( deadline - now ))
    288         if [[ "$active" == white ]]; then w=$rem; b=$sb; else w=$sw; b=$rem; fi
    289         if (( rem <= 0 )); then
    290             [[ -n "$sent" ]] && : > "$sent"   # ": > archivo" crea/vacia el archivo (la senial)
    291             curl -s -XPOST "localhost:$port" -d "abort" >/dev/null 2>&1
    292             return
    293         fi
    294         hdr=$(_clock_str "$w" "$b" "$active")
    295         # "curl" le habla al servidor de control de fzf para cambiar el encabezado.
    296         curl -s -XPOST "localhost:$port" -d "change-header($hdr)" >/dev/null 2>&1 || return
    297         sleep 1
    298     done
    299 }
    300 
    301 # _flush_tty: descarta lo que haya quedado tipeado en el teclado. Sin esto, el
    302 # Enter que confirma un fzf (o que termina un texto pegado) puede "colarse" en el
    303 # SIGUIENTE fzf y elegir su opcion resaltada sin querer. Es seguro llamarlo entre
    304 # menus: ahi la persona no esta tipeando nada que nos importe.
    305 _flush_tty() { read -r -t 0.1 -N 100000 _ </dev/tty 2>/dev/null || true; }
    306 
    307 # pick_resume_source: elegir desde donde retomar: una partida guardada en saves/
    308 # (mas nuevas primero), o un PGN/FEN pegado. Deja RESUME_FILE, RESUME_PGN o
    309 # RESUME_FEN. Devuelve 1 si la persona cancela.
    310 pick_resume_source() {
    311     RESUME_FILE=""; RESUME_FEN=""; RESUME_PGN=""
    312     local paste_fen="✎ Paste a FEN string…"
    313     local paste_pgn="✎ Paste a PGN game…"
    314     local src
    315     _flush_tty
    316     # Armamos la lista: las dos opciones de "pegar" arriba, despues los archivos de
    317     # saves/ ordenados por fecha (ls -1t = uno por linea, mas nuevo primero).
    318     src=$( { printf '%s\n%s\n' "$paste_pgn" "$paste_fen"
    319              [[ -d "$SAVE_DIR" ]] && (cd "$SAVE_DIR" && ls -1t 2>/dev/null); } \
    320          | fzf --reverse --border --height=60% --prompt="Resume from: " \
    321                --header="  Pick a saved game, or paste a PGN game / FEN position")
    322     [[ -z "$src" ]] && return 1
    323     if [[ "$src" == "$paste_pgn" ]]; then
    324         printf '  Paste the PGN, then press Enter and Ctrl-D:\n' >/dev/tty
    325         local pgn; pgn=$(cat </dev/tty)   # "cat </dev/tty" lee todo hasta Ctrl-D
    326         [[ -z "$pgn" ]] && return 1
    327         if ! load_pgn_string "$pgn"; then
    328             printf '  Could not parse that PGN.\n' >/dev/tty; sleep 1; return 1
    329         fi
    330         RESUME_PGN="$pgn"
    331     elif [[ "$src" == "$paste_fen" ]]; then
    332         printf '  Paste FEN: ' >/dev/tty
    333         local fen; read -r fen </dev/tty
    334         [[ -z "$fen" ]] && return 1
    335         if ! fen_looks_valid "$fen"; then
    336             printf '  That does not look like a FEN.\n' >/dev/tty; sleep 1; return 1
    337         fi
    338         RESUME_FEN="$fen"
    339     else
    340         RESUME_FILE="$SAVE_DIR/$src"   # eligio un archivo guardado
    341     fi
    342     return 0
    343 }
    344 
    345 # puzzle_mode: muestra puzzles del archivo puzzles.tsv uno por uno. El jugador
    346 # elige su jugada con el selector normal; el juego le dice si acerto o fallo y
    347 # muestra la solucion. Con fzf elige cual puzzle jugar.
    348 puzzle_mode() {
    349     local pfile="${SCRIPT_DIR}/puzzles.tsv"
    350     [[ -f "$pfile" ]] || { echo "No puzzle file found: $pfile" >&2; return 1; }
    351 
    352     # Construimos la lista de puzzles para el selector.
    353     local -a names=() fens=() solutions=()
    354     local name fen sol
    355     while IFS=$'\t' read -r name fen sol; do
    356         [[ "$name" == \#* || -z "$name" ]] && continue
    357         names+=("$name"); fens+=("$fen"); solutions+=("$sol")
    358     done < "$pfile"
    359 
    360     (( ${#names[@]} == 0 )) && { echo "No puzzles found." >&2; return 1; }
    361 
    362     while true; do
    363         # Selector de puzzle.
    364         local pick idx
    365         pick=$(printf '%s\n' "${names[@]}" \
    366              | fzf --reverse --border --height=60% \
    367                    --prompt="Puzzle: " \
    368                    --header=$'  Choose a puzzle  (Esc to exit)\n')
    369         [[ -z "$pick" ]] && return 0
    370 
    371         # Buscamos el indice del puzzle elegido.
    372         for (( idx=0; idx<${#names[@]}; idx++ )); do
    373             [[ "${names[$idx]}" == "$pick" ]] && break
    374         done
    375 
    376         # Cargamos la posicion del puzzle.
    377         setup_fen "${fens[$idx]}"
    378         HISTORY=(); HIST_FULLMOVE=0; HIST_LINE=""
    379         ALLOW_TAKEBACK=0; UNDO_STACK=()
    380         TC_ENABLED=0; CLOCK_white=0; CLOCK_black=0
    381         POSCOUNT=(); POSCOUNT["$(position_key)"]=1
    382         PGN_SAN=(); PGN_UCI=(); PGN_STARTFEN="${fens[$idx]}"
    383         export CHESS_FEN="${fens[$idx]}"
    384 
    385         # Extraemos las jugadas de la solucion (pueden ser varias: jugada + respuesta).
    386         local -a sol_moves=()
    387         read -r -a sol_moves <<< "${solutions[$idx]}"
    388         local step=0 failed=0
    389 
    390         while (( step < ${#sol_moves[@]} )); do
    391             local expected="${sol_moves[$step]}"
    392             local is_player_turn=1
    393             # Los pasos pares (0, 2, 4…) son del jugador; los impares son la respuesta del rival.
    394             (( step % 2 == 1 )) && is_player_turn=0
    395 
    396             if (( is_player_turn )); then
    397                 # Turno del jugador: usa el selector normal.
    398                 clear
    399                 HL_TITLE="Puzzle: ${names[$idx]}  — find the best move!"
    400                 _draw_board; echo ""
    401                 local mv; mv=$(ui_pick_move)
    402                 case "$mv" in
    403                     ""|QUIT|RESIGN) failed=2; break ;;   # 2 = salio
    404                 esac
    405 
    406                 # Comparamos la jugada elegida con la esperada (solo origen+destino).
    407                 if [[ "${mv:0:4}" == "${expected:0:4}" ]]; then
    408                     PGN_SAN+=("$(move_to_san "$mv")"); PGN_UCI+=("$mv")
    409                     apply_move "$mv"
    410                     record_history "$TURN" "$mv"
    411                     export CHESS_FEN="$(get_fen)"
    412                     (( step++ ))
    413                 else
    414                     failed=1; break
    415                 fi
    416             else
    417                 # Respuesta del rival (la hacemos automaticamente).
    418                 render_board
    419                 echo "  Opponent plays ${expected}..."
    420                 sleep 0.8
    421                 PGN_SAN+=("$(move_to_san "$expected")"); PGN_UCI+=("$expected")
    422                 apply_move "$expected"
    423                 record_history "$TURN" "$expected"
    424                 export CHESS_FEN="$(get_fen)"
    425                 (( step++ ))
    426             fi
    427         done
    428 
    429         # Resultado.
    430         render_board
    431         case "$failed" in
    432             0) echo "  Correct! Well done." ;;
    433             1) echo "  Not quite. The solution was: ${solutions[$idx]}" ;;
    434             2) ;;   # salio sin resolver
    435         esac
    436         [[ "$failed" != 2 ]] && read -r -p "  Press Enter to continue..." _ </dev/tty
    437     done
    438 }
    439 
    440 # pgn_viewer: visor paso a paso de una partida PGN. Navega con flechas derecha/izquierda
    441 # (o j/k), q para salir. Guarda un snapshot del tablero en cada jugada para poder
    442 # ir para atras sin recalcular.
    443 pgn_viewer() {
    444     local file=$1
    445     local pgn_text; pgn_text="$(cat "$file")"
    446     load_pgn_string "$pgn_text" || { echo "Could not parse PGN: $file" >&2; return 1; }
    447 
    448     # Guardamos la lista de jugadas en UCI (ya aplicadas por load_pgn_string en GAME_PGN)
    449     # y el FEN de inicio. Luego reproducimos desde el principio guardando snapshots.
    450     local -a uci_moves=()
    451     local startfen="$GAME_STARTFEN"
    452     local total="${#GAME_PGN[@]}"
    453 
    454     # Para pasar de SAN a UCI necesitamos reproducir la partida y anotar las jugadas.
    455     POSCOUNT=(); setup_fen "$startfen"; POSCOUNT["$(position_key)"]=1
    456     local i
    457     for (( i=0; i<total; i++ )); do
    458         local uci; uci=$(san_to_uci "${GAME_PGN[$i]}") || { echo "Parse error at move $i" >&2; return 1; }
    459         uci_moves+=("$uci")
    460         apply_move "$uci"
    461     done
    462 
    463     # Ahora construimos los snapshots: un array de FENs, uno por posicion (0 = inicio).
    464     local -a snaps=()
    465     POSCOUNT=(); setup_fen "$startfen"; POSCOUNT["$(position_key)"]=1
    466     snaps+=("$(get_fen)")
    467     for (( i=0; i<total; i++ )); do
    468         apply_move "${uci_moves[$i]}"
    469         snaps+=("$(get_fen)")
    470     done
    471 
    472     # Bucle de navegacion.
    473     local pos=0   # posicion actual (0 = antes de la primera jugada)
    474     settings_load
    475     while true; do
    476         setup_fen "${snaps[$pos]}"
    477         # Titulo: numero de jugada y jugada jugada.
    478         if (( pos == 0 )); then
    479             HL_TITLE="Start position  (← → to navigate, q to quit)"
    480         else
    481             local mn=$(( (pos + 1) / 2 )) color
    482             (( pos % 2 == 1 )) && color="White" || color="Black"
    483             HL_TITLE="Move ${mn} (${color}): ${GAME_PGN[$((pos-1))]}   [${pos}/${total}]  (← → / q)"
    484         fi
    485         render_board
    486 
    487         # Leemos una tecla. Las flechas mandan 3 bytes: ESC [ A/B/C/D.
    488         local key esc bracket dir
    489         IFS= read -r -s -N1 key </dev/tty 2>/dev/null
    490         if [[ "$key" == $'\x1b' ]]; then
    491             IFS= read -r -s -N1 -t 0.1 bracket </dev/tty 2>/dev/null
    492             IFS= read -r -s -N1 -t 0.1 dir     </dev/tty 2>/dev/null
    493             if [[ "$bracket" == '[' ]]; then
    494                 case "$dir" in
    495                     C) key="right" ;;   # flecha derecha
    496                     D) key="left"  ;;   # flecha izquierda
    497                     *) key=""      ;;
    498                 esac
    499             else
    500                 key=""   # ESC solo = ignorar
    501             fi
    502         fi
    503 
    504         case "$key" in
    505             right|l|' ') (( pos < total )) && (( pos++ )) ;;
    506             left|h)      (( pos > 0     )) && (( pos-- )) ;;
    507             q|Q)         break ;;
    508         esac
    509     done
    510     HL_TITLE=""
    511     return 0
    512 }
    513 
    514 # main_menu: el menu interactivo que aparece al correr ./ches.sh sin argumentos. La
    515 # primera palabra de cada opcion es el modo al que mapea; al elegir uno, pide los
    516 # datos que falten (host/puerto) y vuelve a llamar a main(). Repite hasta que se
    517 # elige un modo jugable o se sale.
    518 main_menu() {
    519     # Dos ayuditas para preguntar puerto y direccion por teclado.
    520     ask_port() { local p; read -r -p "  Port [9000]: " p </dev/tty; echo "${p:-9000}"; }
    521     ask_host() { local h; read -r -p "  ${1:-Host} address: " h </dev/tty; echo "$h"; }
    522 
    523     while true; do
    524         local choice port host
    525         choice=$(printf '%s\n' \
    526             "hotseat   Two players at this terminal" \
    527             "ai        Play vs the computer (Stockfish)" \
    528             "watch     Watch the computer play itself (AI vs AI)" \
    529             "puzzle    Solve tactical puzzles" \
    530             "host      Host a LAN game (peer-to-peer, you play White)" \
    531             "join      Join a peer's LAN game (you play Black)" \
    532             "server    Play via a LAN relay server" \
    533             "rejoin    Reconnect to a game in progress (after a drop)" \
    534             "resume    Resume a saved game" \
    535             "theme     Change board theme (${CHESS_THEME})" \
    536             "aids      Toggle visual aids (eval bar, opening name)" \
    537             "selftest  Run the engine self-test" \
    538             "help      Show command-line usage" \
    539             "quit      Exit" \
    540           | fzf --reverse --border --with-nth=1.. --height=100% \
    541                 --prompt="Select: " \
    542                 --header=$'  ♚  B A S H   C H E S S  ♚\n  (or pass hotseat/host/join/server on the command line)\n')
    543 
    544         # "${choice%% *}" se queda con la primera palabra (el modo elegido).
    545         case "${choice%% *}" in
    546             hotseat)  main hotseat; return ;;
    547             ai)       main ai; return ;;
    548             watch)    main watch; return ;;
    549             puzzle)   puzzle_mode; continue ;;
    550             host)     port=$(ask_port); main host "$port"; return ;;
    551             join)     host=$(ask_host); [[ -z "$host" ]] && continue
    552                       port=$(ask_port); main join "$host" "$port"; return ;;
    553             server)   host=$(ask_host "Relay server"); [[ -z "$host" ]] && continue
    554                       port=$(ask_port); main server "$host" "$port"; return ;;
    555             rejoin)   host=$(ask_host "Survivor's"); [[ -z "$host" ]] && continue
    556                       port=$(ask_port); main rejoin "$host" "$port"; return ;;
    557             theme)    ui_pick_theme; continue ;;
    558             aids)     ui_settings_menu; continue ;;
    559             resume)   pick_resume_source || continue
    560                       # Encabezado lindo segun de donde se retoma.
    561                       local rmode rhdr="${RESUME_PGN:+pasted PGN game}"
    562                       rhdr="${rhdr:-${RESUME_FEN:-${RESUME_FILE##*/}}}"
    563                       _flush_tty
    564                       rmode=$(printf '%s\n' \
    565                           "hotseat  Continue at this terminal" \
    566                           "host     Continue over LAN (you host as White)" \
    567                         | fzf --reverse --border --height=40% --with-nth=1.. \
    568                               --prompt="Resume how: " --header="  ${rhdr}")
    569                       case "${rmode%% *}" in
    570                           hotseat) main hotseat; return ;;
    571                           host)    port=$(ask_port); main host "$port"; return ;;
    572                           *)       RESUME_FILE=""; RESUME_FEN=""; RESUME_PGN=""; continue ;;
    573                       esac ;;
    574             selftest) main --selftest ;;          # sale via run_selftest
    575             help)     usage; echo; read -r -p "  Press Enter to return to the menu..." _ </dev/tty ;;
    576             quit|"")  echo "  Bye."; exit 0 ;;
    577         esac
    578     done
    579 }
    580 
    581 # main: el "despachante". Mira el primer argumento y arranca lo que corresponda.
    582 main() {
    583     # "${1:-menu}" usa el 1er argumento, o "menu" si no hay ninguno.
    584     case "${1:-menu}" in
    585         menu)
    586             main_menu; exit 0
    587             ;;
    588         --selftest)
    589             source "$SCRIPT_DIR/lib/selftest.sh"
    590             run_selftest; exit $?
    591             ;;
    592         # Subcomandos OCULTOS: dibujar la vista previa del tablero desde $CHESS_FEN.
    593         # Los llama fzf por detras (ver ui.sh); no son para usar a mano.
    594         __ppiece)  setup_fen "$CHESS_FEN"; ui_preview_piece "$2"; exit 0 ;;
    595         __pmove)   setup_fen "$CHESS_FEN"; ui_preview_move  "$2"; exit 0 ;;
    596         # Subcomando oculto: el actualizador del reloj en vivo del selector (ver ui.sh).
    597         __clocktick) clock_ticker "$2"; exit 0 ;;
    598         --help|-h) usage; exit 0 ;;
    599         # Los modos de juego. Algunos validan que vengan los argumentos necesarios
    600         # ("$2"/"$3"); si no, muestran la ayuda y salen con error.
    601         hotseat)   transport_init hotseat ;;
    602         ai)        transport_init ai ;;
    603         watch)     transport_init aivai ;;
    604         puzzle)    puzzle_mode; exit $? ;;
    605         host)      [[ -n "$2" ]] || { usage; exit 1; }; transport_init host "$2" ;;
    606         join)      [[ -n "$3" ]] || { usage; exit 1; }; transport_init join "$2" "$3" ;;
    607         rejoin)    [[ -n "$3" ]] || { usage; exit 1; }; transport_init rejoin "$2" "$3" ;;
    608         server)    [[ -n "$3" ]] || { usage; exit 1; }; transport_init server "$2" "$3" ;;
    609         resume)    [[ -n "$2" ]] || { usage; exit 1; }
    610                    [[ -f "$2" ]] || { echo "No such save file: $2" >&2; exit 1; }
    611                    RESUME_FILE="$2"; transport_init hotseat ;;
    612         fen)       [[ -n "$2" ]] || { usage; exit 1; }
    613                    fen_looks_valid "$2" || { echo "Not a valid FEN: $2" >&2; exit 1; }
    614                    RESUME_FEN="$2"; transport_init hotseat ;;
    615         pgn)       [[ -n "$2" ]] || { usage; exit 1; }
    616                    [[ -f "$2" ]] || { echo "No such PGN file: $2" >&2; exit 1; }
    617                    pgn_viewer "$2"; exit $? ;;
    618         *)         usage; exit 1 ;;
    619     esac
    620     play   # despues de preparar el modo, arrancamos la partida
    621 }
    622 
    623 main "$@"   # finalmente, llamamos a main con todos los argumentos de la linea de comandos